Docs/Use casesv0.0.1-preview

Wallets

Stored balances with an engine-enforced overdraft policy — top-ups, spends, and holds where the "can't go negative" rule is not application code.

On this page
  1. Account topology
  2. Top-up, spend, decline
  3. Holds count against the balance
  4. The invariants you get for free
  5. Related

A wallet is a stored balance with one non-negotiable rule: it must never go below what the user actually has. In most stacks that rule lives in application code — which means it lives in every service that can write, and fails the day one of them forgets. In TulaDB it’s a property of the account.

Account topology

A treasury account funds wallets; each user wallet is an account whose limit_low = 0 makes overdraft impossible at the engine; merchant accounts receive spends.

W = c.create_ledger(LedgerInstance(ledger_id=0, currency=840)).ledger_id
TREASURY, USER_WALLET, MERCHANT, APP = 100, 5001, 6001, 1   # integer ids

c.create_account(AccountCreate(id=TREASURY, ledger_id=W, currency=840,
                               limit_low=-10**15, limit_high=10**15))
c.create_account(AccountCreate(id=USER_WALLET, ledger_id=W, currency=840,
                               limit_low=0,           # the overdraft policy
                               limit_high=10**9))     # optional balance cap
c.create_account(AccountCreate(id=MERCHANT, ledger_id=W, currency=840,
                               limit_low=0, limit_high=10**15))

The treasury’s deeply negative floor is deliberate: in double-entry, funding wallets drives the treasury negative by exactly the amount in circulation — its balance is your float, auditable at any moment.

Top-up, spend, decline

c.transfer(TransferCreate(source_id=APP, ledger_id=W, currency=840,
        debit_account_id=TREASURY, credit_account_id=USER_WALLET,
        amount=1_000, txn_id="topup-1"))            # wallet: 1000

c.transfer(TransferCreate(...,
        debit_account_id=USER_WALLET, credit_account_id=MERCHANT,
        amount=400, txn_id="spend-1"))              # wallet: 600

c.transfer(TransferCreate(..., amount=700, txn_id="spend-2"))
# → declined: ERR_LIMIT (in Python, raises LedgerError with status_code 2).
#   The engine checked 600 − 700 < limit_low and refused —
#   atomically, at write time, no matter which service submitted it.

The decline is a durable record like any other: it appears in the rejected listing, and redelivering spend-2 replays the decline rather than re-deciding it. In the Python SDK a declined single-op transfer raises LedgerError with status_code == 2 (ERR_LIMIT) — the batch path returns per-item statuses instead — so your spend handler catches it and shows “insufficient balance.”

Holds count against the balance

A pending debit is unavailable money: with 600 posted in the wallet and 500 held, a further 200 spend is declined — held funds cannot be double-spent.

c.reserve(TransferCreate(..., amount=500, txn_id="hold-1"))   # e.g. pending withdrawal
c.transfer(TransferCreate(..., amount=200, txn_id="spend-3")) # → ERR_LIMIT (raises LedgerError)

This gives you two-step wallet flows (withdrawal requests, pending purchases) with the same safety as instant spends — see Holds for the lifecycle, and add a TTL so an abandoned hold releases itself.

The invariants you get for free

  • Conservation: treasury outflow always equals the sum of wallet balances plus spends — debits equal credits on every transfer, so float reconciliation is a query, not a nightly job.
  • Idempotent top-ups: a redelivered topup-1 (webhook retry, queue redelivery) returns OK_DUPLICATE — the user is credited exactly once, for as long as history is retained (forever by default).
  • Auditability: every wallet’s history — including its declines — is immutable and queryable by account key, AccountKey(ledger_id=W, currency=840, id=USER_WALLET).

Accounts and limits · Card payments · Rewards and closed-loop economies · Spend controls