How money actually moves, step by step
Seven flows, each traced through the real code paths: what the client sends, which service handles it, when the ledger posts, and what happens on restart or failure.
a. Top-up settlement
Every top-up method (topup/mvr|card|crypto|apple-pay|google-pay) goes through
the same SettlementEngine state machine: authorize → pending →
(webhook or sweeper poll) → completed | failed. All state lives in the
PaymentSettlement row, not in memory — a server restart mid-settlement loses
nothing; the sweeper simply picks the row back up on its next tick.
NextPollAt and settlement status live in Postgres, so the sweeper resumes exactly where it left off after a crash.- Client requests a top-up.
POST /wallet/topup/<method>with anIdempotency-Key— a retry of the same key replays the original response rather than double-charging. - Immediate authorize. The handler calls the configured
PaymentProvider(sandbox by default, or Stripe/local_bank if configured) — this is a synchronous call, so failures here return an error to the client right away. - Pending row created. A
PaymentSettlementrow and aTransactionrow are both written aspending, andtxn.createdis published on the event bus. The client gets back202-shaped data withtransaction.status = "pending"— it does not wait for settlement. - Settlement happens off the request path. Either the provider calls the public webhook, or the sweeper's next tick polls
GetStatus. Sandbox provider settles deterministically after PAYMENTS_SANDBOX_SETTLE_DELAY (5s default). - Ledger posts atomically with the status flip.
complete()runsledger.Post(external_funding → user) andApplyToWalletinside the same DB transaction as marking the settlementcompleted— the wallet balance and the ledger can never disagree about a given top-up. - Give-up path. After PAYMENTS_MAX_SETTLEMENT_ATTEMPTS (20) failed polls, the settlement is marked
failedandtxn.failedis published — no money moves.
b. P2P transfer
Synchronous, single-request flow: PIN check, ordered account locks, one ledger posting,
done. Unlike top-ups, P2P does not currently publish txn.* events
— docs/wiring/payments.md itself notes this as an explicit gap ("Not covered by
this wave: ProcessP2PTransfer, ProcessBillPayment,
ProcessNFCPayment, ProcessQRPayment"), and it's still true in the
current code — confirmed by the complete event-emitter grep in the Security page.
- PIN gate first.
pinguard.Verifyruns before anything else touches money — wrong PIN 3 times locks the user out for 15 minutes (PIN_LOCKED), independent of account password. - Fee computed. 0.5% of the amount, floored at 1 MVR / 0.25 USD, rounded to 2dp (
calculateTransferFee). - One ledger posting, three legs. Sender debited
amount + fee, recipient creditedamount,feessystem account creditedfee— see Ledger → Worked examples. - Synchronous, not async. Unlike a top-up, there's no pending state — the transfer either fully succeeds inside one request or rolls back entirely (
ErrOverdraft→INSUFFICIENT_BALANCEif the sender can't cover it).
c. NAQD mint
Mint is asynchronous: the API call only creates a queued NAQDOperation; a
background worker does the actual ledger + chain work. Large mints route through
maker-checker before the worker will touch them.
reference-REVERSAL) and publishes system.alert rather than leaving the user's fiat debited with no NAQD delivered.- Quote resolution. Rate =
silverPriceUSD / 31.1034768(grams per troy ounce). Either amount-first (amount_naqd→ USD cost) or spend-first (USD/MVR amount → NAQD out), no mint fee. - Oracle fallback chain, in order: live median of CoinGecko + Metals-API (if METALS_API_KEY set) → last known-good price if within ORACLE_STALENESS_THRESHOLD (15m) → most recent admin-set manual rate →
ORACLE_STALEerror. Every degraded step publishessystem.alert. - Maker-checker gate. If the USD-equivalent value is ≥ NAQD_APPROVAL_THRESHOLD_USD (default 5000), the operation is created as
requires_approval— before anything touches the ledger, so a reject costs nothing to undo. - Worker claims and executes. A guarded
UPDATE ... WHERE status='pending'prevents two worker ticks from double-claiming the same operation. Ledger posts first, then the chain call — on CHAIN_ENV=sandbox (default) this is in-memory and idempotent per reference. - Client polls
GET /naqd/operations/:idfor the status transitionpending → processing → completed, withtx_hashonce the chain step lands.
d. NAQD on-chain withdrawal
Self-custody withdrawal: a user's custodial (ledger-tracked) NAQD balance moves to a wallet address they control. This is the only path that ever moves tokens to a user-supplied external address — the platform never generates or holds per-user chain keys.
ChainClient.CustodyAddress() is one platform-owned address per chain.The sandbox chain is idempotent per (kind, reference), so a crash-and-retry never double-sends. Solana and Polygon have no equivalent dedup primitive yet — if the process crashes between a live chain call succeeding and the DB recording the tx hash, the worker's recovery sweep will resubmit. This is documented in blockchain-service/README.md's "Known limitation" section and is real, not hypothetical, for anyone running CHAIN_ENV=live today. See Security → Known limitations.
e. Card auth → hold → clearing → capture
Card authorizations arrive as signed webhooks (the sandbox processor's SimulateAuth
stands in for a real network). The defining design choice: a hold only ever moves
Wallet.LockedBalance — the ledger is untouched until the hold actually
clears. See Ledger → The card-hold design for why.
f. TnG reload (partner rail)
First partner corridor: Touch 'n Go eWallet reload (Malaysia), currently a sandbox rail implementation. A quote bridges through USD (MVR/MYR has no direct rate, so it resolves MVR→USD→MYR), then execution posts to a per-rail settlement sub-account.
g. Admin WebSocket event flow
One process-global, in-memory pub/sub bus feeds every admin dashboard connection. Delivery is best-effort by design — see the callout below for exactly what that means and where the Vue side currently falls short of consuming everything it receives.
events.Bus.Publish's own doc comment: "we'd rather a slow admin dashboard
silently miss an event than have Publish pay the cost of draining someone else's channel
under lock." Anything that needs guaranteed delivery persists separately (in-app
notifications, the audit log) and publishes an event in addition, not instead.
All 18 event types (12 from the contract + 6 additive: session.*,
user.updated, kyc.approved/rejected/updated) reach the browser —
the WebSocket client (reconnect, heartbeat, parsing) is fully built. But today only
system.alert (→ toast) is actually consumed by a specific handler; everything
else lands only in a generic 60-item feed array. dashboardStore.applyLiveTick(),
transactionsStore.prependLive() and partnersStore.refreshOperationsQuiet()
all exist to consume stats.tick / txn.* / partner.operation
respectively, but none is currently wired to the realtime store's stream. See
Admin Console → WebSocket live layer.