Custodial treasury
The treasury is the cash boundary of a real-money game — the bridge between a player’s real on-chain wallet and your game treasury. It does exactly two things with real funds, and nothing else:
deposit— pull tokens from the player’s real wallet into the treasury. An implicit top-up: the player is loading balance to play with.withdraw— send tokens from the treasury back to the player’s real wallet. An explicit cash-out: the player pressed Withdraw.
In between, per-minute play never touches the chain — it lives entirely in your game’s own balances (backed by the ledger or pool custody). The treasury is game-agnostic: it moves the exact amount you name at the boundary and knows nothing about betting rules.
sk_ key and must run on your server. The player-facing side — the actual deposit and withdraw UI a user interacts with — is documented separately in Deposit and Withdraw & export. This page is the money-moving backend behind it.The mental model
Think of one shared treasury account per app+currency, on-chain. Every player’s deposits add to it; every withdrawal takes from it. Your game tracks who owns what off-chain. The single invariant that must always hold:
treasury on-chain balance == Σ all players’ game balances + accrued house rakeThe treasury never enforces this per-move — you do, by only ever depositing what a player loaded and only ever withdrawing what a player is owed. The
custodialBalance read gives you the numbers to check it.There’s also a hard tenant guard: a withdraw may never exceed your own app’s treasury sub-balance (Σyour-deposits − Σyour-withdrawals). One app’s key can never drain the shared pool holding another app’s funds.
Refs and idempotency
Every move takes a ref — your idempotency key. Use something you own and can regenerate deterministically (a deposit-intent id, a withdraw-request id). The contract:
- A retry with the same ref never moves fresh money. The server resolves the recorded on-chain signature against the chain and replays the result.
- A ref binds to one move — same user, currency, environment, amount, kind. Reusing a ref for a different move is rejected.
- Because deposits/withdrawals are real on-chain transfers, they confirm on chain time. Your call can return before finality; use
status(ref)to resolve the final fate.
The status state machine
When a move is ambiguous — your process crashed, the network blipped, you’re not sure it landed — call server.treasury.status(ref). It is authoritative: when it returns a terminal not-moved state it tombstones the ref, so a later same-ref retry can no longer silently move the money after you’ve unwound.
| state | Meaning | What you do |
|---|---|---|
none | No such ref — nothing was ever attempted. | Nothing to reconcile. |
orphan | Reserved, never signed ⇒ definitely did NOT move. Tombstoned. | Safe to unwind. Use a NEW ref for a fresh attempt. |
dead | Its signed tx provably expired/failed ⇒ did NOT move. Tombstoned. | Safe to unwind. Use a NEW ref for a fresh attempt. |
stuck | Signed and still in-flight. | WAIT. Do NOT unwind, do NOT re-call this round. |
confirmed | The transfer landed. | Done — the money moved. |
status while a deposit/withdraw for the same ref is still in flight in another request — that’s what stuck is for. And once you receive orphan or dead and unwind, treat the ref as spent: a fresh intent needs a new ref.Reconciliation: custodialBalance
server.treasury.custodialBalance returns three numbers so you can audit against the conservation invariant — all bigint base units:
| Field | What it is |
|---|---|
spendable | The player's SPENDABLE real-wallet balance — their own wallet, not yet deposited. This is the “Wallet” bucket the game UI shows. |
userNet | The player's net on-chain position with the treasury = Σ confirmed deposits − Σ confirmed withdrawals. |
treasuryTotal | The treasury's total on-chain balance for this currency — the aggregate pool across all players. |
Only confirmed transfers count toward userNet. This read is display-grade (briefly cached, ≤5s) — never gate a money move on it; the boundary moves read the chain directly. Use it to reconcile and to render the player’s balance.
End to end
A deposit that tops a player up, a withdrawal that cashes them out, a reconciler that resolves an ambiguous move, and a balance read — the whole surface:
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 userId = "usr_abc"; // from wallet.verifySession(sessionToken)
const currency = 3; // ENUMGPCurrency: USDC (micro-USDC, 10^6)
// ── DEPOSIT (implicit top-up) ───────────────────────────────────────────────
// ref is YOUR idempotency key — deterministic, owned by you. A retry never re-moves.
const depositRef = "deposit:usr_abc:2026-07-13:intent-42";
try {
const { railRef } = await wallet.treasury.deposit({
userId,
currency,
amount: 50_000_000n, // 50 USDC — integer base units, never a float
ref: depositRef,
});
// railRef is the confirmed on-chain signature (or null if not yet final).
await creditGameBalance(userId, 50_000_000n); // your books
} catch (err) {
// Ambiguous outcome (timeout/crash)? Don't guess — reconcile by ref below.
}
// ── WITHDRAW (explicit cash-out) ────────────────────────────────────────────
// You have already verified the player is owed this amount off-chain.
const withdrawRef = "withdraw:usr_abc:req-9001";
const { railRef: wSig } = await wallet.treasury.withdraw({
userId,
currency,
amount: 12_000_000n, // 12 USDC back to their real wallet
ref: withdrawRef,
});
// ── RECONCILE an ambiguous move by its ref ──────────────────────────────────
const s = await wallet.treasury.status(depositRef);
switch (s.state) {
case "confirmed":
await ensureGameBalanceCredited(userId, BigInt(s.amount!)); // it landed
break;
case "orphan":
case "dead":
await unwindDepositIntent(userId); // it did NOT move — safe to unwind
// NOTE: the ref is now tombstoned — a fresh attempt needs a NEW ref.
break;
case "stuck":
// still in-flight — WAIT, retry status later, do not unwind.
break;
case "none":
// never attempted — nothing to do.
break;
}
// ── RECONCILIATION READ (audit + UI balance) ────────────────────────────────
const bal = await wallet.treasury.custodialBalance({ userId, currency });
// bal.spendable → the player's own-wallet "Wallet" bucket (bigint)
// bal.userNet → Σ confirmed deposits − Σ confirmed withdrawals (bigint)
// bal.treasuryTotal→ the whole treasury pool for this currency (bigint)
// Invariant to hold in your books:
// treasuryTotal == Σ all players' game balances + accrued house rakeHandling failure correctly
- A call threw / timed out. Don’t assume it failed. Call
status(ref).confirmedmeans it moved;orphan/deadmean it didn’t (and the ref is now spent);stuckmeans wait. - You want to retry a move. Retry with the same ref — it’s crash-safe and won’t double-spend. Only mint a new ref after
statustombstoned the old one asorphan/dead. - Withdraw rejected as insufficient. Your app’s treasury sub-balance can’t cover it. That’s the tenant guard — it means your deposits and off-chain accounting have drifted; reconcile before retrying.
Where to go next
- Choose a money model — how treasury composes with the ledger and pools.
- Deposit — the player-facing deposit flow that triggers
treasury.deposit. - Withdraw & export — the player-facing cash-out and self-custody export.
- API reference — the raw
/v1/treasury/*endpoints behind the SDK.