Pool-escrow custody

Use pool custody when the money at stake is a shared pot — a pari-mutuel game where everyone stakes into one pool, an outcome resolves, and the winners split it. Each bet’s stake is locked into an on-chain escrow keyed by your pool id; when the outcome is known the whole pool settles in one atomic call — rake plus every payout — guarded by a conservation check. If the pool never runs, you refund every stake.

Like the rest of the money surface, custody is game-agnostic: it moves exact amounts you name, keyed by your poolId/betId, and knows nothing about odds or rules. Your game decides who won and how much; the wallet holds the escrow and enforces that the books balance. one CustodyPool == one on-chain asset-lock escrow (PDA).

Backend only
Every custody call uses your sk_ key and must run server-to-server. The browser must never reach lock, settle, or refund. Resolve your game userId → the IAMGame userId (via wallet.verifySession) before calling.

Anchored on-chain (asset locks)

Each pool is a real on-chain asset lock — a Solana PDA the signer controls. When you lockStake, the stake is escrowed in that PDA on-chain; when you settlePool, the signer unlocks the pool and pays every winner + rake in one atomic move, then closes the lock (reclaiming rent). So the pot isn’t a number in a database you have to trust — it’s locked by the Solana runtime, and the settlement is enforced on-chain.

Fast play, on-chain certainty
This is the “fast off-chain experience, eventually persisted on-chain” model. Your game runs the round at UI speed and calls the API — you don’t wait on a block per action. The money, though, is anchored in on-chain asset locks and settled atomically with a conservation guard, so players get blockchain-grade certainty without the latency of signing a transaction for every bet. Compare with the ledger, which is purely off-chain (fastest, no on-chain anchor per pool) — see Choose a money model.

Pools vs. treasury vs. ledger

QuestionAnswer
Is the product a shared pot everyone stakes into?Use pool custody.
Is it fast per-player play against the house?Use the ledger — it holds per-player money, not a pool.
Is it just loading and cashing out real balance?Use the treasury.

Pools are heavier than the ledger — real escrow, a lock per stake. They’re for outcomes that resolve on a schedule (a match ends, a bracket closes), not for a millisecond hot loop.

The lifecycle

Four calls, in order. The pool is created implicitly on the first lockStake for a poolId; it ends at settlePool or refundPool.

CallWhenIdempotent on
lockStakePer bet — pull one player's stake into the pool escrow.betId
confirmPlacementRight after your bet is durably committed — proves the lock is backed by a real bet (orphan guard).betId
settlePoolOutcome known — fan out rake + every winner's payout, atomically.poolId
refundPoolPool voided — return every stake (symmetric with settle).poolId

lockStake & the orphan guard

lockStake moves one stake into escrow and is idempotent on betId — a retry replays, never double-locks. But a lock can succeed and then your bet write can fail, leaving an escrowed stake with no bet behind it. confirmPlacement(betId) closes that gap: call it once your bet row is committed. A lock that’s never confirmed is an orphan, and the reconciler auto-releases it back to its owner after a timeout — it never counts toward the pool’s settled total.

settlePool & the conservation guard

settlePool is the one atomic move that ends the pool. You pass the rake (the house cut) and the full list of payouts (betIduserId → amount). Before moving a cent, the server independently recomputes what the pool actually holds on-chain and asserts:

Conservation guard — fails closed
Σ payouts + rake == Σ locked
If the numbers don’t match, settle is refused and nothing moves. The wallet will never let a settlement pay out more or less than the pool contains. Get your payout math exactly right — including rake — or the whole settle is rejected.

The rake simply stays in the house authority account. A loser is just a bettor with no entry in payouts. If you want to pass some of the rake up a referral network, do it after settle with server.referrals.distribute (see Choose a money model).

A full pool lifecycle

Three players stake into pool 7788. The outcome resolves; two win. The game computes rake and payouts, then settles once — and the conservation guard checks the books:

TS
import { IAMGameWalletServer } from "@iamgame/wallet-sdk-server";

const wallet = new IAMGameWalletServer({
  secretKey: process.env.IAMGAME_WALLET_SECRET_KEY!, // sk_ — BACKEND ONLY
  baseUrl: "https://api-wallet.iamgame.com/v1",
});

const poolId   = "7788";   // your gp_pool.id — the escrow key
const currency = 3;        // ENUMGPCurrency: USDC (micro-USDC, 10^6)

// ── OPEN + LOCK STAKES ──────────────────────────────────────────────────────
// One lock per bet. betId is YOUR bet id — the per-lock idempotency key.
// The pool is created implicitly on the first lock for this poolId.
async function placeBet(userId: string, betId: string, stake: bigint) {
  await wallet.custody.lockStake({ userId, currency, stake, poolId, betId });
  await commitBetRow(betId);                    // your DB write
  await wallet.custody.confirmPlacement(betId); // orphan guard — lock is now backed by a real bet
}

await placeBet("usr_alice", "bet:alice:7788", 10_000_000n); // 10 USDC
await placeBet("usr_bob",   "bet:bob:7788",   10_000_000n); // 10 USDC
await placeBet("usr_cara",  "bet:cara:7788",  10_000_000n); // 10 USDC
// Σ locked = 30_000_000

// ── SETTLE (outcome known) ──────────────────────────────────────────────────
// Your game computes rake + each winner's payout. Alice & Bob won; Cara lost.
// The books MUST balance: Σ payouts + rake == Σ locked  → the guard fails closed otherwise.
const rake = 3_000_000n;                                    // 10% house cut
const payouts = [
  { betId: "bet:alice:7788", userId: "usr_alice", payout: 13_500_000n },
  { betId: "bet:bob:7788",   userId: "usr_bob",   payout: 13_500_000n },
  // Cara is simply absent from payouts — a loser is a bettor with no payout entry.
];
// 13_500_000 + 13_500_000 + 3_000_000 (rake) == 30_000_000 ✓

const { railRef } = await wallet.custody.settlePool({
  poolId,
  currency,
  rake,
  payouts,
});
// railRef is the on-chain settlement signature. Idempotent on poolId — a retry replays.

// ── REFUND (pool voided instead of settled) ─────────────────────────────────
// Symmetric with settle. Return every landed stake to its owner.
await wallet.custody.refundPool({
  poolId,
  currency,
  refunds: [
    { betId: "bet:alice:7788", userId: "usr_alice", stake: 10_000_000n },
    { betId: "bet:bob:7788",   userId: "usr_bob",   stake: 10_000_000n },
    { betId: "bet:cara:7788",  userId: "usr_cara",  stake: 10_000_000n },
  ],
});

Rules to internalize

  • Always confirm. lockStake then commit your bet then confirmPlacement. An unconfirmed lock is an orphan and gets auto-released — it won’t be in the pool at settle.
  • Settle must balance. Σ payouts + rake == Σ locked, exactly, in base units. The guard fails closed — a mismatch moves nothing. Do the arithmetic in bigint, never floats.
  • Idempotent everywhere. Locks key on betId; settle/refund key on poolId. Retries are safe and replay the stored result.
  • Settle or refund, not both. A pool ends once. A voided pool refunds; a resolved pool settles.
  • Only real currencies. Custody is for real-money escrow (USDC is wired in v1). Free/PLAY currency never reaches it.

Where to go next