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.

Backend only
Every treasury call uses your 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:

Conservation invariant
treasury on-chain balance == Σ all players’ game balances + accrued house rake
The 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 refyour 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.

stateMeaningWhat you do
noneNo such ref — nothing was ever attempted.Nothing to reconcile.
orphanReserved, never signed ⇒ definitely did NOT move. Tombstoned.Safe to unwind. Use a NEW ref for a fresh attempt.
deadIts signed tx provably expired/failed ⇒ did NOT move. Tombstoned.Safe to unwind. Use a NEW ref for a fresh attempt.
stuckSigned and still in-flight.WAIT. Do NOT unwind, do NOT re-call this round.
confirmedThe transfer landed.Done — the money moved.
Don't race status against a live move
Don’t call 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:

FieldWhat it is
spendableThe player's SPENDABLE real-wallet balance — their own wallet, not yet deposited. This is the “Wallet” bucket the game UI shows.
userNetThe player's net on-chain position with the treasury = Σ confirmed deposits − Σ confirmed withdrawals.
treasuryTotalThe 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:

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 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 rake

Handling failure correctly

  • A call threw / timed out. Don’t assume it failed. Call status(ref). confirmed means it moved; orphan/dead mean it didn’t (and the ref is now spent); stuck means 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 status tombstoned the old one as orphan/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.