Skip to content

API Reference

FairWins exposes two interfaces, and they do different jobs.

The contracts are the system of record. Everything that moves value — creating, accepting, resolving, claiming, refunding a wager; buying or redeeming a membership — is an on-chain transaction or view call, signed by the member's own wallet. The rest of this page is that interface, from JavaScript (ethers.js v6). No server sits between you and it, and none can.

The member HTTP API is a read-and-quote surface, added in spec 095. A member may sign a scoped, expiring capability token in the app and hand it to a program — including an AI agent over the MCP server — to read their own wagers, membership, and live fee rates, and to have unsigned typed data built for them. It holds no key and can sign nothing: every write still returns to the member's wallet. See Member API for the token format, the verification order, and the endpoints; the served OpenAPI 3.1 document is the machine-readable contract, at GET /v1/member/openapi.json on the relay gateway (for example https://relay.fairwins.app/v1/member/openapi.json, when the module is enabled).

Two other HTTP surfaces exist and are not member APIs: the relay gateway's public read proxies and its optional gasless relay (Gasless Intents). Neither is required — every gasless flow keeps a self-submit fallback, and every proxy degrades to an honest unavailable state.

For exact contract signatures see Contract Interfaces; for addresses see the Smart Contracts guide.

Setup

ABIs are produced by npx hardhat compile under artifacts/contracts/ (the frontend keeps trimmed copies in frontend/src/abis/).

import { ethers } from "ethers";

const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();

const registry = new ethers.Contract(WAGER_REGISTRY_ADDRESS, WagerRegistryABI, signer);
const membership = new ethers.Contract(MEMBERSHIP_MANAGER_ADDRESS, MembershipManagerABI, signer);
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, signer);

USDC has 6 decimals — use ethers.parseUnits("10", 6) for a 10 USDC stake.

Membership

Creating or accepting wagers requires an active WAGER_PARTICIPANT_ROLE membership.

const ROLE = ethers.keccak256(ethers.toUtf8Bytes("WAGER_PARTICIPANT_ROLE"));

// Check status: tier 0 = None, 1 = Bronze … 4 = Platinum
const m = await membership.getMembership(userAddress, ROLE);
const isActive = m.tier > 0 && m.expiresAt > Math.floor(Date.now() / 1000);

// Purchase (USDC approval first). Tier prices come from getTierConfig.
const cfg = await membership.getTierConfig(ROLE, 1 /* Bronze */);
await (await usdc.approve(MEMBERSHIP_MANAGER_ADDRESS, cfg.priceUSDC)).wait();
await (await membership.purchaseTierWithTerms(ROLE, 1, acceptedTermsHash)).wait();

Membership.activeCount and monthCount track your concurrent and monthly usage against the tier's LimitscreateWager reverts once either limit is hit.

Membership vouchers (feature 026)

A MembershipVoucher is a transferable ERC-721 bearer claim — buy it with USDC, then hold, gift, or resell it. It confers no membership until it is redeemed (which burns it and writes a soulbound membership to the redeemer). Mint is on the voucher contract; redeem is on the manager.

const voucher = new ethers.Contract(MEMBERSHIP_VOUCHER_ADDRESS, MembershipVoucherABI, signer);

// Buy a voucher for a tier (USDC approval to the voucher contract first).
const cfg = await membership.getTierConfig(ROLE, 2 /* Silver */);
await (await usdc.approve(MEMBERSHIP_VOUCHER_ADDRESS, cfg.priceUSDC)).wait();
const mintTx = await voucher.mint(ROLE, 2);
const mintRc = await mintTx.wait();
const tokenId = mintRc.logs
    .map(l => { try { return voucher.interface.parseLog(l); } catch { return null; } })
    .find(e => e?.name === "VoucherMinted").args.id;

// Gift / resell: it's a standard ERC-721.
await voucher.transferFrom(myAddress, recipientAddress, tokenId);

// Redeem (the holder): burns the voucher, grants the (role, tier) it carries.
await (await membership.redeemVoucher(tokenId, acceptedTermsHash)).wait();

Redemption screens the redeemer through SanctionsGuard (the minter is not screened — screening happens at redeem time). MembershipVoucher is immutable and carries a best-effort EIP-2981 royalty (default 2.5%, 5% cap).

Creating a wager

const stake = ethers.parseUnits("10", 6);
const now = Math.floor(Date.now() / 1000);

// 1. Approve the registry for your stake
await (await usdc.approve(WAGER_REGISTRY_ADDRESS, stake)).wait();

// 2. Create — your stake moves into escrow
const tx = await registry.createWager(
    opponentAddress,            // who can accept
    ethers.ZeroAddress,         // arbitrator (required for ThirdParty type)
    USDC_ADDRESS,               // stake token (must pass isAllowedToken)
    stake,                      // creatorStake
    stake,                      // opponentStake (differs for Offer odds)
    now + 6 * 3600,             // acceptDeadline  (≤ 30 days out)
    now + 86400 + 48 * 3600,    // resolveDeadline (≤ 180 days out)
    1,                          // ResolutionType.Creator ("Me" — UI no longer offers Either)
    ethers.ZeroHash,            // oracle conditionId (oracle types only)
    true,                       // creatorIsYes (oracle types only)
    metadataHash,               // keccak256 of the terms
    "ipfs://<cid>"              // terms location (optionally encrypted)
);
const receipt = await tx.wait();

// wagerId from the WagerCreated event
const event = receipt.logs
    .map(l => { try { return registry.interface.parseLog(l); } catch { return null; } })
    .find(e => e?.name === "WagerCreated");
const wagerId = event.args.wagerId;

ResolutionType: 0 Either, 1 Creator, 2 Opponent, 3 ThirdParty, 4 Polymarket, 5 ChainlinkDataFeed, 6 ChainlinkFunctions, 7 UMA.

For oracle types, pass the registered condition ID and which side you're taking (creatorIsYes). For ThirdParty, pass a non-zero arbitrator. createWagerWithTerms(...) additionally binds the accepted terms-version hash on-chain.

Open challenges (feature 024)

An open challenge is a wager posted with no named opponent, gated by a four-word claim code. The code derives an off-chain key whose address is the on-chain claimAuthority; the same code discovers the wager, decrypts its terms, and signs acceptance. Silver+ membership is required to create one; any active tier may take it. Stakes are equal by construction.

// Create — derive claimAuthority from the code off-chain, then post.
// (See frontend/src/utils/claimCode/ for code generation + key derivation.)
await (await usdc.approve(WAGER_REGISTRY_ADDRESS, stake)).wait();
const tx = await registry.createOpenWager(
    claimAuthority,             // address derived from the four-word code
    ethers.ZeroAddress,         // arbitrator (required for ThirdParty)
    USDC_ADDRESS, stake,
    now + 48 * 3600,            // acceptDeadline
    now + 9 * 86400,            // resolveDeadline
    0,                          // ResolutionType.Either (or ThirdParty/oracle)
    ethers.ZeroHash, true,
    metadataHash, "ipfs://<cid>"
);
// wagerId from the OpenWagerCreated event.

// Take — look the wager up by the code's authority, then accept with an
// EIP-712 signature from the code key BOUND TO THE TAKER (not replayable).
const wagerId = await registry.openWagerIdForClaim(claimAuthority); // 0 = no match
const w = await registry.getWager(wagerId);
await (await usdc.approve(WAGER_REGISTRY_ADDRESS, w.opponentStake)).wait();
await (await registry.acceptOpenWager(wagerId, signature)).wait();

The taker MUST approve their stake before acceptOpenWager — it escrows the matching stake via transferFrom, so an un-approved call reverts with ERC20: transfer amount exceeds allowance. isOpenChallenge(wagerId) distinguishes an open challenge from a named-opponent wager.

Accepting, declining, cancelling

// Opponent: approve their stake, then accept (wager goes Open → Active)
await (await usdc.approve(WAGER_REGISTRY_ADDRESS, opponentStake)).wait();
await (await registry.acceptWager(wagerId)).wait();

// Opponent can reject (creator refunded immediately)
await registry.declineWager(wagerId);

// Creator can withdraw an un-accepted offer
await registry.cancelOpen(wagerId);

// Anyone can sweep stale open offers past their acceptDeadline
await registry.batchExpireOpen([id1, id2, id3]);

Both createWager and acceptWager screen the participants through SanctionsGuard — a SanctionedAddress revert means the address is deny-listed or flagged by the Chainalysis oracle.

Resolving

// Participant / arbitrator resolution (per the wager's ResolutionType)
await registry.declareWinner(wagerId, winnerAddress);

// Draw: first call records consent, the matching call from the other
// party settles it and returns each side's own stake
await registry.declareDraw(wagerId);
await registry.revokeDraw(wagerId);              // back out before the other consents
const { creatorAgreed, opponentAgreed } = await registry.drawConsent(wagerId);

// Oracle settlement — permissionless once the source has resolved
await registry.autoResolveFromPolymarket(wagerId);  // ResolutionType.Polymarket
await registry.autoResolveFromOracle(wagerId);      // Chainlink / UMA types

Claiming and refunds

// Winner pulls the full pot (once)
await registry.claimPayout(wagerId);

// Refunds — Open past acceptDeadline refunds the creator;
// Active past resolveDeadline refunds both sides
await registry.claimRefund(wagerId);

Reading wagers

const w = await registry.getWager(wagerId);
// w.status: 0 None, 1 Open, 2 Active, 3 Resolved, 4 Cancelled, 5 Refunded, 6 Draw
// w.creator / w.opponent / w.arbitrator / w.winner
// w.creatorStake / w.opponentStake (uint128, token decimals)
// w.acceptDeadline / w.resolveDeadline (unix seconds)
// w.metadataHash / w.metadataUri  — terms hash + IPFS pointer

// Paginated per-user queries
const count = await registry.getUserWagerCount(user);
const ids = await registry.getUserWagerIds(user, 0, 50);
const wagers = await registry.getUserWagers(user, 0, 50);

Listening to events

registry.on(registry.filters.WagerCreated(null, creatorAddress), (id, creator, opponent) => {
    console.log(`wager ${id}: ${creator} vs ${opponent}`);
});

Lifecycle events, in order of a typical happy path: WagerCreated (or OpenWagerCreated for open challenges) → WagerAcceptedWagerResolvedPayoutClaimed. Other exits: WagerCancelled, WagerDeclined, WagerRefunded, DrawProposed/DrawRevoked/WagerDrawn. Oracle links emit PolymarketLinked / OracleConditionLinked at creation. Membership emits VoucherSet / MembershipRedeemed (vouchers) and MembershipRevoked; the voucher contract emits VoucherMinted. Moderation emits AccountFrozen / AccountUnfrozen.

Encryption keys

For end-to-end encrypted terms, participants publish keys in KeyRegistry:

const keyRegistry = new ethers.Contract(KEY_REGISTRY_ADDRESS, KeyRegistryABI, signer);

if (!(await keyRegistry.hasKey(opponentAddress))) {
    // opponent must registerKey() before you can encrypt a wager for them
}
await keyRegistry.registerKey(publicKeyBytes);            // 32–2048 bytes
const pk = await keyRegistry.getPublicKey(opponentAddress);

The envelope format (X-Wing hybrid KEM + ChaCha20-Poly1305) is specified in the Envelope Encryption Spec.

Common errors

Revert Cause
SanctionedAddress(account) Address deny-listed or flagged by the sanctions oracle
Membership-related revert on create No active tier, or monthly/concurrent limit reached
ERC-20 transferFrom failure Missing/insufficient USDC approval or balance
acceptWager revert Wrong address (named opponent only), deadline passed, or not Open
acceptOpenWager revert Bad/old claim signature, not an open challenge, expired, or you're the creator/arbitrator
transfer amount exceeds allowance on accept Approve your stake to the registry before acceptWager/acceptOpenWager
declareWinner revert Caller not authorized for the wager's resolution type, or wager not Active
claimPayout revert Caller is not the winner, or already paid
claimRefund revert Relevant deadline hasn't passed yet
Frozen-account revert Address frozen by an Account Moderator (isFrozen(user))