ServicesTreasury

Treasury

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. Every example on this page was executed against a live server.

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. 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, each reproduced live:

  • 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; page with next_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"
    }
  ],
  "next_cursor": "1787921230033-0",
  "wallet_id": "wal_e625...c2f"
}

Pass --cursor 1787921230033-0 to continue (verified: the next page picks up at the following entry); a null next_cursor is the end.

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 in the Treasury CONFIG reference.

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

On this page