Skip to content

Treasury

Try the interactive example ↓

A double-entry virtual economy engine: developer-defined currencies, wallets for users, orgs, or arbitrary entities, atomic multi-leg transfers, holds for escrow, faucet/sink supply control, and economy analytics. It knows amounts and accounts, never why - the "why" lives in the calling service.

When to reach for it: game currencies with quest payouts and shop sinks, marketplace escrow, creator tips split atomically across wallets, API-credit allowances with holds during long jobs, internal scrip.

When not to: no real money exists here - Treasury moves integers between wallets and never touches payment processors. Usage metering and spend budgets belong to Meter; points programs with campaigns and reward catalogs belong to Loyalty.

Concepts

  • Domains partition economies: everything lives in exactly one {domain} path segment, and nothing crosses domains.
  • Currencies are developer-defined and admin-created: name, symbol, display decimals, an overdraft policy (DENY by default, or ALLOW_TO_LIMIT / UNLIMITED), optional supply controls. Amounts are always integers.
  • Wallets hold one currency for one principal (auth_user, org, or entity). Balance splits into balance, held, and available (balance minus held); debits check available.
  • Transfers are atomic sets of debit and credit legs that must net to zero per currency - all legs commit or none do.
  • Holds reserve funds without spending them; capture consumes them, release or expiry frees them.
  • Faucets and sinks are the only way value enters or leaves a currency: a faucet wallet mints by going negative, a sink burns what it receives. Both are admin-created and feed the analytics.
  • Idempotency keys are mandatory on transfers and settles: a retry with the same key replays the stored result (idempotent_replay: true, HTTP 200 instead of 201) instead of moving money twice.

Currencies and wallets

Creating a currency requires a platform admin token (or the treasury role):

snug treasury currencies create -d docs4-econ -i cur_gold -n Gold -s G \
  --decimals 0 --max-supply 1000000 --faucets-require-tag
snug treasury currencies list -d docs4-econ    # any authenticated user

Any user can create a wallet for their own auth_user principal; org and entity principals, and the faucet/sink kinds, are admin-only (both verified to fail with 403 insufficient_permissions otherwise):

snug treasury wallets create -d docs4-econ -c cur_gold -p auth_user:docs-wave
snug treasury wallets create -d docs4-econ -c cur_gold -p entity:mint -k faucet  # admin

Wallet ids are deterministic in (domain, principal, currency, kind): creating the same wallet twice returns the existing one, balance intact. wallets list defaults to the caller's own principal; reading or listing another principal's wallets is 403 insufficient_permissions. It lists in wallet id order on the standard list contract, with filters on currency_id, kind, frozen, balance, available and created_at (--filter 'currency_id[eq]=cur_gold') and --sort -balance. Admins can also freeze a wallet (wallets freeze --frozen true): debits from it then fail with 409 wallet_frozen even for its owner (verified) until it is unfrozen.

Transfers

A transfer is N debit legs plus M credit legs. Wallets start at 0 - value first enters through a faucet (Supply and analytics). After minting 5000 gold, one user pays another:

BUYER=wal_e625a4f3362f057632597e8fb5e52c2f    # ids from wallets create
SELLER=wal_51eb705df009f3d78fc1c749bb2490e2
snug treasury transfer -d docs4-econ -k docs4-sale-1 \
  --debit $BUYER:1200 --credit $SELLER:1200 \
  --tag marketplace_sale --ref handshake:deal_042

Verified on both ledgers: a DEBIT 1200 in the buyer's history and a CREDIT 1200 in the seller's, pointing at the same transfer_id. A non-admin may only debit wallets they control (403 otherwise) but may credit any wallet.

Over HTTP the same operation is POST /api/v1/treasury/{domain}/transfers:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"idempotency_key":"docs4-http-1","legs":[
        {"wallet_id":"wal_e625...c2f","direction":"DEBIT","amount":25},
        {"wallet_id":"wal_51eb...0e2","direction":"CREDIT","amount":25}],
      "tags":["marketplace_sale"]}' \
  http://localhost:4000/api/v1/treasury/docs4-econ/transfers
{
  "status": 201,
  "msg": "Created",
  "data": {
    "id": "txn_bec13dcfc7414166935019b8f8ba5627",
    "legs": [
      { "wallet_id": "wal_e625...c2f", "direction": "DEBIT", "amount": 25 },
      { "wallet_id": "wal_51eb...0e2", "direction": "CREDIT", "amount": 25 }
    ],
    "tags": ["marketplace_sale"],
    "status": "COMMITTED",
    "created_at": "2026-08-28T12:50:26.901868Z",
    "idempotent_replay": false
  }
}

Refusals:

  • Legs that do not net to zero per currency: 400 unbalanced_transfer.
  • A zero or negative leg amount: 400 validation error (legs[0].amount: lower than 1) - there are no zero-value transfers.
  • Debiting past available under the default DENY overdraft policy: 409 insufficient_funds, with the available balance in the message.
  • Repeating an idempotency key is not a refusal: the original committed transfer comes back with idempotent_replay: true and the same id.

Holds and settle

A hold reserves funds without spending them - the escrow primitive behind bids, checkout, and long-running jobs:

snug treasury holds place -d docs4-econ -w $BUYER -a 1000 --ref auction:bid_007
snug treasury wallets get -d docs4-econ --wallet-id $BUYER
# balance: 3800, held: 1000, available: 2800

Held funds still count in balance but not available, so a transfer that would dip into them fails with 409 insufficient_funds. Three ways out of a hold, all verified:

  • Release (holds release) frees the reservation; the hold becomes RELEASED, and acting on it again is 409 hold_not_active.
  • Capture (holds capture) consumes the funds: the wallet is debited and the value leaves circulation as a burn (ledger tag hold_capture). Capture may be partial - capturing 700 of a 1000 hold debits 700 and releases the remaining 300.
  • Expiry: a hold placed with --expires-in auto-releases as EXPIRED. The sweeper runs about once a minute, so a hold can outlive expires_at by up to that long (verified: a 2-second hold was still capturable at 5 seconds, gone at 75).

settle captures several holds and applies extra legs - which must balance among themselves - as one atomic, idempotent transfer; the response is the committed transfer plus a captured_holds array:

snug treasury settle -d docs4-econ -k docs4-settle-1 \
  --hold hold_8f10...a2c --hold hold_c483...7f6 \
  --debit $BUYER:50 --credit wal_0a3d...013:50 --tag escrow_settle

Wallet history

Every leg lands in the wallet's ledger with a running balance_after. Entries return oldest first, one page per cursor:

snug --output json treasury wallets history -d docs4-econ \
  --wallet-id $BUYER --limit 1
{
  "entries": [
    {
      "transfer_id": "txn_cbccc515ed914ba2849cced6ad119690",
      "direction": "CREDIT",
      "amount": 5000,
      "balance_after": 5000,
      "tags": ["quest_reward"],
      "created_at": "2026-08-28T12:47:10.033618Z"
    }
  ],
  "page": { "next_cursor": "eyJ2IjoxLCJzIjoi...", "has_more": true },
  "wallet_id": "wal_e625...c2f"
}

Pass page.next_cursor as --cursor to continue, or --all to read the whole ledger; has_more: false is the end. The history resumes from a stream position, so it pages by cursor only (page= is refused); --total counts the entries.

Supply, faucets, and analytics

How value is minted and burned, supply caps, and the admin analytics (supply counters, per-tag flow reports, top holders) have their own page: Supply and analytics.

Limits and configuration

Transfers take up to 16 legs and 16 tags, history pages cap at 200 entries with the per-wallet stream trimmed to the newest 100000, and idempotency keys replay for 24 hours by default - all tunable via the TREASURY_* variables.

Reference

  • Treasury API - every endpoint, callable
  • Related: Meter for usage metering and budgets, Loyalty for points programs with rewards, Auction and Guild for services that pair naturally with wallets and holds

Interactive example

Manage wallets, holds, and transfers for a virtual economy. Balanced ledger entries record each movement so you can trace where credits went.

An illustrative example that runs in your browser. No account or API calls required.