Top-up
Buyer cash becomes spendable credit, valued at the buy rate.
Source src/operations/topUp.ts#L39 · topUpsrc/operations/registry.ts#L43 · REGISTRYsrc/contract.ts#L68 · kind 'topUp'src/webhooks.ts#L160 · toTopUp
Top-up
top-up turns a buyer's cleared cash into spendable credit. It raises a user's
spendable balance and records the USD that paid for
it.
It runs after a card or wallet charge clears, so the caller is the trusted payment service, not the
buyer. You name the user, the credit amount, and the funding source:
let outcome = await economy.submit({
kind: "topUp",
idempotencyKey: "idem_0",
actor: { kind: "system", service: "payments" },
userId: "usr_buyer",
amount: decodeAmount("50.00", "CREDIT"),
source: "card",
});
// → { status: "committed", transaction: { … } }
// usr_buyer's spendable balance rose by 50.00 credits.
The credit amount is what the user receives. What they paid in USD is derived from the
buy rate, not passed in.
Parameters
The payload fields, beyond the kind tag, are:
| Field | Type | Default | Description |
|---|---|---|---|
idempotencyKey | string | — (required) | A retried submit with the same key runs at most once. See idempotency. |
actor | Actor | — (required) | Who is asking. Must be system or operator. |
userId | string | — (required) | The user whose spendable balance is credited. |
amount | Amount | — (required) | The credit to issue. Must be CREDIT and strictly positive. |
source | string | — (required) | The funding rail (card, steam, …). Selects the credits' maturity horizon and must be non-empty after trimming whitespace. |
The source is more than a label. It sets how long the new credits are held before they can be
spent or cashed out — see maturity. An unrecognized source falls
back to the long default horizon, never a fast one.
Returns
It returns an Outcome:
committed— the credit was issued. Thetransactioncarries the issuance posting (the buyer's credits going up), the leg the buyer cares about.duplicate— theidempotencyKeywas already used. The earlier transaction is returned unchanged, and nothing new posts.rejected— a velocity or maintenance-window decline; see reason codes.
Postings
A top-up posts two balanced transactions, because one posting can't mix CREDIT and USD. The
returned transaction is the first.
The first posting issues the credit. It raises the buyer's spendable balance and records the same
amount against STORED_VALUE, the running count of
all credits in circulation:
| Leg | Account | Amount |
|---|---|---|
| Debit | STORED_VALUE | amount (CREDIT) |
| Credit | spendable(userId) | amount (CREDIT) |
The second posting accounts for the cash the buyer paid. The USD splits two ways. The backing value
(amount × par) is held in TRUST_CASH to cover the new credits. The buy-vs-par gap — the
platform spread — is recognized as revenue in REVENUE_USD:
| Leg | Account | Amount |
|---|---|---|
| Debit | TRUST_CASH | backing = ceil(amount × par) (USD) |
| Debit | REVENUE_USD | margin = gross − backing (USD) |
| Credit | USD_CLEARING | gross = ceil(amount × buy) (USD) |
The REVENUE_USD leg posts only when the margin is positive. An exact-par purchase (no spread) stays
a two-leg cash move.
Both conversions round up. Rounding the backing up keeps TRUST_CASH covering the whole spendable
balance valued at par, so the books never read unbacked. Rounding the gross up to match keeps the
margin at or above zero. The cost is at most one minor unit over the exact price.
Authorization
top-up is restricted to a privileged Actor: a
trusted system service (the verified payment path) or a human operator. It mints spendable
credit, so an end user can never issue one — a user principal is rejected with UNAUTHORIZED.
In production, the system top-up is driven by a verified processor
webhook. toTopUp builds the operation from a cleared PurchaseEvent, keyed off the provider's
eventId so a replayed webhook dedupes.
Reason codes
A top-up can return these reason codes as a
rejected outcome:
| Code | When |
|---|---|
RISK_DENIED | The user's recent top-up volume crossed the configured velocity limit. A top-up counts against the same per-user velocity window as a spend. |
ECONOMY_PAUSED | A maintenance window is in effect. Only a user actor is paused — and a user can't top up at all — so a system or operator top-up is never paused. |
Structurally invalid requests throw faults rather than returning a rejected outcome. A blank
source or a non-CREDIT amount throws MALFORMED_OPERATION. A zero or negative amount throws
INVALID_AMOUNT. These are broken requests, not expected declines.
Preconditions and invariants
A top-up holds the books straight on both currencies:
- The
amountmust beCREDITand strictly positive; a wrong currency or non-positive amount throws. - Both postings net to zero within their currency, so the ledger stays balanced and conserved.
TRUST_CASHrises by at leastamount × par, so the credits are backed the moment they're issued — trust cash always covers the spendable balance valued at par.- The new credits carry their
sourceand top-up time as a lot, which fixes when they mature and become spendable and cashable.