Fixed-Odds Offers with the Poll SDK

V5 adds offers: one side posts a stake at a fixed price, others take it in parts. This guide covers the instruction builders, the accounts, and the maths.

The V5 model

V2 to V4 bets pool everyone's stake and split it by outcome share. V5 is different: money is matched pairwise at a fixed ratio.

  • Contract. A two-sided question with a cutoff, an event start, and a resolution deadline at most 30 days after the start. Sides are sideA and sideB.
  • Offer. A creator locks creatorTotal on one side and asks for takerTarget on the other. The ratio is the price.
  • Position. One taker's matched money on one offer. Each take allocates a proportional slice of the creator's stake to that position.
  • Settlement. After the contract resolves, each position is settled on its own. Winner takes both sides minus a fee on profit. Void refunds both.

Who can do what

MethodSignerNotes
initializeOfferConfig / updateOfferConfigPoll adminSets resolver, fee (max 2500 bps), and the global switch.
initializeContractPoll adminYou cannot create contracts. Offer on the ones Poll publishes.
resolveContractPoll resolversideA, sideB, or void. Only between event start and the deadline.
timeoutContractAnyoneVoids an unresolved contract once the deadline passes. Protects takers from a resolver that never shows up.
initializeOffer / takeOfferAny userNeeds an on-chain Poll user account and USDC.
closeOfferCreator, or anyone after cutoffRefunds the unmatched creator stake.
settlePosition / closeOfferPoolAnyonePermissionless. The fee payer covers rent and gas. Poll runs a settler, but you can settle your own.

If you do not want to hold keys, the Offers REST API does the same flow with Poll signing on your behalf.

Setup and sending

V5 methods live under sdk.instructions.v5 and return instructions, not sent transactions. You add them to a transaction and send it yourself. That keeps you free to batch, add priority fees, or use a fee payer.

setup.ts
import { SDK } from "@solworks/poll-sdk";
import {
  Connection,
  Keypair,
  Transaction,
  sendAndConfirmTransaction,
  type TransactionInstruction,
} from "@solana/web3.js";

const connection = new Connection("RPC_URL");
const wallet = Keypair.fromSecretKey(/* your key */ new Uint8Array(64));

const sdk = SDK.build({
  connection,
  wallet: {
    publicKey: wallet.publicKey,
    signTransaction: async (tx) => { tx.sign(wallet); return tx; },
    signAllTransactions: async (txs) => { txs.forEach((tx) => tx.sign(wallet)); return txs; },
  },
});

async function send(...ixs: TransactionInstruction[]) {
  const tx = new Transaction().add(
    sdk.instructions.modifyComputeUnitsIx(400_000),
    ...ixs,
  );
  return sendAndConfirmTransaction(connection, tx, [wallet]);
}

Amounts are micro-USDC. Pass an integer string, a number, or a BN. Never floats. Your wallet needs a Poll user account first; see the quick start.

Find open contracts

Read the config once, then list contracts and keep the ones still open and before cutoff. The REST contracts endpoint gives the same list with titles, teams, and crests, which the chain does not store.

typescript
const config = await sdk.accounts.offerConfigV5.single(
  sdk.addresses.offerConfigV5.get(),
);
if (!config.offersEnabled) throw new Error("Offers are switched off");

const now = Math.floor(Date.now() / 1000);
const contracts = (await sdk.accounts.contractV5.all())
  .filter(({ account }) =>
    "open" in account.status && account.cutoffAt.toNumber() > now,
  );

const contract = contracts[0].publicKey;

Create an offer

Stake 8 USDC on side B and ask for 10 USDC on side A. Takers may take in slices of at least 1 USDC. The builder derives the offer id from your user account, so it returns the new offer address for you to keep.

create-offer.ts
const { ix, offer } = await sdk.instructions.v5.initializeOffer({
  contract,
  creatorOutcome: "sideB",
  creatorTotal: "8000000",   // 8 USDC, locked now
  takerTarget: "10000000",   // 10 USDC wanted on side A
  minimumTake: "1000000",    // 1 USDC
  expectedFeeBps: config.feeBps,
});

await send(ix);
console.log("offer", offer.toBase58());

Rules checked on-chain: all three amounts above zero, minimumTake at most takerTarget, the minimum take must allocate at least 1 micro of creator stake, and expectedFeeBps must equal the config.

Take an offer

Read the offer first. You need its revision, which increments on every fill and close. The program rejects a take whose revision is stale, so you never match at terms you did not see.

take-offer.ts
const before = await sdk.accounts.offerV5.single(offer);

const ix = await sdk.instructions.v5.takeOffer({
  offer,
  takeAmount: "5000000",            // 5 USDC on side A
  expectedRevision: before.revision,
});

await send(ix);

const position = await sdk.accounts.positionV5.single(
  sdk.addresses.positionV5.get(offer, wallet.publicKey),
);
console.log(position.takerAmount.toString(), position.creatorAmount.toString());
// "5000000" "4000000"

A take must be at least minimumTake, unless it is exactly the remaining target. Taking the last slice flips the offer to Filled. Repeat takes by the same wallet grow the same position.

Close an offer

typescript
const current = await sdk.accounts.offerV5.single(offer);
const ix = await sdk.instructions.v5.closeOffer({
  offer,
  expectedRevision: current.revision,
});
await send(ix);

Close refunds creatorTotal - creatorAllocated to the creator and marks the offer Closed. Matched positions are untouched. The creator may close at any time; after the contract cutoff, anyone may. Close fails when nothing is unmatched.

Settle positions

Once the contract is resolved or void, settle each position. The offer must be Filled or Closed first. When the last position settles, the offer becomes Settled and the pool can be closed to reclaim rent.

settle.ts
const contractAccount = await sdk.accounts.contractV5.single(contract);
if ("open" in contractAccount.status) throw new Error("Not resolved yet");

const positions = await sdk.accounts.positionV5.all([
  { memcmp: { offset: 8, bytes: offer.toBase58() } }, // positions on this offer
]);

for (const { account } of positions) {
  if (!("open" in account.status)) continue;
  const ix = await sdk.instructions.v5.settlePosition({ offer, taker: account.taker });
  await send(ix);
}

const done = await sdk.accounts.offerV5.single(offer);
if ("settled" in done.status) {
  await send(await sdk.instructions.v5.closeOfferPool({ offer }));
}

If the resolver misses the deadline, anyone can call timeoutContract to void the contract, then settle. Every position then refunds both sides with no fee.

Allocation and fee math

Creator stake is allocated cumulatively so rounding never strands dust:

allocated_before = floor(taker_filled          * creator_total / taker_target)
allocated_after  = floor((taker_filled + take) * creator_total / taker_target)
creator_slice    = allocated_after - allocated_before

When the target fills exactly, the whole creator stake is allocated. A take whose slice rounds to zero is rejected.

Contract resultCreator payoutTaker payoutFee
Creator's sidecreator + taker - fee0taker × feeBps / 10000
Taker's side0taker + creator - feecreator × feeBps / 10000
Voidcreatortaker0

Fee is charged on the winner's profit only, rounded down in micro-USDC. Payouts plus fee always equal the position's total collateral. Max fee is 2500 bps.

Accounts and addresses

AccountHolds
OfferConfigV5resolverAuthority, feeBps, offersEnabled. One per protocol.
ContractV5contractId, termsHash, eventStartAt, cutoffAt, resolutionDeadline, status.
OfferV5contract, creator, creatorOutcome, creatorTotal, creatorAllocated, takerTarget, takerFilled, minimumTake, feeBps, status, revision, positionCount.
PositionV5takerAmount, creatorAmount, status, payouts and fee once settled.
Offer poolPDA that owns the offer's USDC token account.

Fetch with sdk.accounts.<name>.single(address) or .all(filters). Enum fields come back as Anchor objects, for example { open: {} }; test with "open" in status. Statuses: offer Open → Filled | Closed → Settled; contract Open → ResolvedSideA | ResolvedSideB | Void; position Open → SettledCreatorWin | SettledTakerWin | Refunded.

Program errors

ErrorWhen
OffersDisabledConfig switch is off. Create and take fail; close and settle still work.
ContractMustBeOpenContract already resolved or void.
InvalidContractTimesNow is past the contract cutoff.
OfferMustBeOpenOffer is Filled, Closed, or Settled.
OfferRevisionMismatchexpectedRevision is stale. Re-fetch the offer and retry.
SelfMatchNotAllowedTaker is the creator.
OfferOverfilledtakeAmount exceeds remaining taker target.
OfferTakeTooSmalltakeAmount below minimumTake and not the exact remainder.
CreatorAllocationIsZeroTake is so small it rounds to zero creator stake. Raise the amount.
OfferFeeMismatchexpectedFeeBps differs from the config. Read the config first.
UnauthorizedOfferCloseNon-creator tried to close before the contract cutoff.
OfferHasNoUnmatchedCollateralNothing left to refund on close.
ContractMustBeResolvedsettlePosition before the contract is resolved or void.
OfferMustBeClosedBeforeSettlementOffer is still Open. Fill it or close it first.
PositionMustBeOpenPosition already settled.

See error handling for how to read these from a failed transaction.