Contents

Top-up

Buyer cash becomes spendable credit, valued at the buy rate.

Source src/operations/topUp.ts#L36topUpsrc/operations/registry.ts#L58REGISTRYsrc/contract.ts#L68kind 'topUp'src/webhooks.ts#L154toTopUp

API topUp

topUp 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:

const outcome = await economy.submit({
  kind: 'topUp',
  idempotencyKey: 'idem_0',
  actor: { kind: 'system', service: 'payments' },
  userId: 'usr_buyer',
  amount: toAmount('CREDIT', 5_000n),
  source: 'card',
});
// → { status: "committed", transaction: { id: "txn_…", … } }
curl -s https://economy.example/submit \
  -H 'content-type: application/json' \
  -d '{
    "kind": "topUp",
    "idempotencyKey": "idem_0",
    "actor": { "kind": "system", "service": "payments" },
    "userId": "usr_buyer",
    "amount": "CREDIT:50.00",
    "source": "card"
  }'

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:

FieldTypeDefaultDescription
idempotencyKeystring(required)A retried submit with the same key runs at most once. See idempotency.
actorPrincipal(required)Who is asking. Must be system or operator.
userIdstring(required)The user whose spendable balance is credited.
amountAmount(required)The credit to issue. Must be CREDIT and strictly positive — and on the purchase catalog when one is configured (below).
sourcestring(required)The funding rail (card, steam, …). Selects the credits’ maturity horizon and must be non-empty after trimming whitespace.

The source 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.

The purchase catalog

With TOP_UP_BUNDLES_MINOR set, the platform sells fixed bundles and amount must be one of them; any other amount faults OP.MALFORMED, because a store client offering an off-catalog amount is miswired, not a user declining. Unset — the default, and how the examples on this page run — any positive amount is accepted.

Returns

topUp resolves to an Outcome.

  • committed: the credit was issued. The transaction carries the issuance posting (the buyer’s credits going up).
  • duplicate: the idempotencyKey was 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:

AccountSideAmount
SYSTEM.STORED_VALUEdebitamount (CREDIT)
spendable(userId)creditamount (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:

AccountSideAmount
SYSTEM.TRUST_CASHdebitbacking = ceil(amount × par) (USD)
SYSTEM.REVENUE_USDdebitmargin = gross − backing (USD)
SYSTEM.USD_CLEARINGcreditgross = 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 — the backing so TRUST_CASH always covers the spendable balance at par, the gross so the margin never goes negative. The cost is at most one minor unit.

Authorization

topUp is restricted to a privileged Principal: 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 AUTH.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 topUp can return one reason code as a rejected outcome:

CodeWhen
RISK_DENIEDThe 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.

The pause gate applies only to user actors, and a user can’t call topUp, so a top-up is never declined with ECONOMY_PAUSED.

A malformed request throws instead:

CodeWhen
OP.MALFORMEDA blank source, a non-CREDIT amount, or an amount off the configured purchase catalog.
MONEY.INVALID_AMOUNTA zero or negative amount.

Preconditions and invariants

A top-up holds the books straight on both currencies:

  • The amount must be CREDIT and 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_CASH rises by at least amount × 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 source and top-up time as a lot, which fixes when they mature and become spendable and payable.

See also