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
sideAandsideB. - Offer. A creator locks
creatorTotalon one side and asks fortakerTargeton 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
| Method | Signer | Notes |
|---|---|---|
| initializeOfferConfig / updateOfferConfig | Poll admin | Sets resolver, fee (max 2500 bps), and the global switch. |
| initializeContract | Poll admin | You cannot create contracts. Offer on the ones Poll publishes. |
| resolveContract | Poll resolver | sideA, sideB, or void. Only between event start and the deadline. |
| timeoutContract | Anyone | Voids an unresolved contract once the deadline passes. Protects takers from a resolver that never shows up. |
| initializeOffer / takeOffer | Any user | Needs an on-chain Poll user account and USDC. |
| closeOffer | Creator, or anyone after cutoff | Refunds the unmatched creator stake. |
| settlePosition / closeOfferPool | Anyone | Permissionless. 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.
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.
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.
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.
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
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.
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_beforeWhen the target fills exactly, the whole creator stake is allocated. A take whose slice rounds to zero is rejected.
| Contract result | Creator payout | Taker payout | Fee |
|---|---|---|---|
| Creator's side | creator + taker - fee | 0 | taker × feeBps / 10000 |
| Taker's side | 0 | taker + creator - fee | creator × feeBps / 10000 |
| Void | creator | taker | 0 |
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
| Account | Holds |
|---|---|
| OfferConfigV5 | resolverAuthority, feeBps, offersEnabled. One per protocol. |
| ContractV5 | contractId, termsHash, eventStartAt, cutoffAt, resolutionDeadline, status. |
| OfferV5 | contract, creator, creatorOutcome, creatorTotal, creatorAllocated, takerTarget, takerFilled, minimumTake, feeBps, status, revision, positionCount. |
| PositionV5 | takerAmount, creatorAmount, status, payouts and fee once settled. |
| Offer pool | PDA 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
| Error | When |
|---|---|
| OffersDisabled | Config switch is off. Create and take fail; close and settle still work. |
| ContractMustBeOpen | Contract already resolved or void. |
| InvalidContractTimes | Now is past the contract cutoff. |
| OfferMustBeOpen | Offer is Filled, Closed, or Settled. |
| OfferRevisionMismatch | expectedRevision is stale. Re-fetch the offer and retry. |
| SelfMatchNotAllowed | Taker is the creator. |
| OfferOverfilled | takeAmount exceeds remaining taker target. |
| OfferTakeTooSmall | takeAmount below minimumTake and not the exact remainder. |
| CreatorAllocationIsZero | Take is so small it rounds to zero creator stake. Raise the amount. |
| OfferFeeMismatch | expectedFeeBps differs from the config. Read the config first. |
| UnauthorizedOfferClose | Non-creator tried to close before the contract cutoff. |
| OfferHasNoUnmatchedCollateral | Nothing left to refund on close. |
| ContractMustBeResolved | settlePosition before the contract is resolved or void. |
| OfferMustBeClosedBeforeSettlement | Offer is still Open. Fill it or close it first. |
| PositionMustBeOpen | Position already settled. |
See error handling for how to read these from a failed transaction.