vault
20 — Data Flows
Data Flows
Every diagram on this page is drawn from the implementation — the module that owns each step is named in the caption, so a claim can be checked against the code rather than taken on trust. Amounts are BigInt minor units end to end; money never passes through a float.
Four flows move value or identity through the system: KYC onboarding, transfers (deposits, sends, payouts), buy / sell, and the reconciliation loop that cross-checks all of the others against the custody and accounting providers. They share three structural rules:
- Postgres is the only datastore. Queues (pg-boss), rate limits, sessions, the webhook inbox and the ledger all live in one database, so a single backup captures the entire system state.
- Provider calls are idempotent. Every outbound mutation carries a deterministic idempotency key; every inbound webhook lands in a unique inbox keyed by provider and event id. Retries and redeliveries are safe in both directions.
- The request path never moves money. Web requests write intent; pg-boss jobs on the worker do the provider calls and the ledger writes.
How to read the diagrams
Solid arrows are the data flow; the label names the protocol or the module that carries it. Use the Expand control on any figure for full detail.
High-level architecture
The split matters for failure analysis: a web outage stops new intent but nothing in flight is lost — jobs are rows in Postgres. A worker outage pauses execution; queued work resumes when it returns. Only the database is critical state, which is why DR is DB-first — see 12-Environments#DR data flows and interactions.
Flow 1 — KYC / onboarding
Identity verification is delegated to Sumsub; the platform never stores identity documents. What crosses the boundary in each direction is narrow and listed under the diagram.
Outbound to Sumsub: user id and a verification level name, in an
HMAC-SHA256-signed request. Inbound from Sumsub: review status, verdict,
and first name / last name / country — the only personal fields persisted,
onto the users row.
The verdict is enforced fail-closed at the money boundary: ensureKycApproved
gates payout and send execution, so an unverified account can hold intent but
cannot move value.
Flow 2 — Transactions and transfers
Three movements share the custody boundary: deposits in, sends out, and bulk payouts out. All three converge on the same pattern — ledger rows under a per-account advisory lock, one deterministic idempotency key per provider mutation, and confirmation before the ledger settles.
Details that carry the correctness weight:
- Payout destinations must be whitelisted. A row is only submitted if
its payee wallet is still
activeand bound to the same Fireblocks external wallet — re-checked at execution time, not just at upload. - Double submission is structurally prevented. If
createTransactionerrors, the executor looks the transfer up by its external id before retrying; a transfer that did go through is adopted, not repeated. - AML screening runs before execution. Every payout address gets an
Elliptic risk score (cached 24 h); rows over the block threshold are
marked
blockedand never reach custody, with an audit row per block. - Fees settle in the right unit. Network fees are charged in the gas asset (ETH for ERC-20, TRX for TRC-20) and converted through the rate oracle before the ledger debit.
Flow 3 — Buy / sell
Card buy runs through Mercuryo's hosted checkout only; the platform never sees a card and never credits from a card payment. Mercuryo prices the purchase at its own checkout and delivers the crypto on chain to the buyer's custody wallet, and the ledger credit comes from custody's confirmed deposit, through the same path as any other inbound transfer.
Mercuryo's paid status never credits by itself: only crypto custody has
confirmed does, so the ledger cannot run ahead of the money. Without
MERCURYO_ENABLED, createBuyOrder refuses with buy-unavailable. The
simulated card checkout that used to stand in for this locally was removed in
phase 6b of 31-Dev-Tooling-Extraction.
Sell is the mirror and is priced the same way — the acquirer quotes it, the crypto leg debits inside one transaction, and the EUR leg carries its own status until the fiat rail confirms, so the ledger never shows a payout as settled ahead of the money.
Convert is the one case with no provider to ask: it is an internal asset-to-asset move with no counterparty, so the rate oracle prices each side against an EUR bridge amount, the platform fee applies to the bridge, and the debit and credit legs run under the same advisory-lock discipline.
Flow 4 — Reconciliation, accounting and providers
The loop that checks the other three. Every hour the worker compares what the ledger says the platform owes against what custody actually holds, and against the accounting provider's independent view.
The halt is the system's only automatic money-stopping control: it is checked at the top of payout execution, so a drift between ledger and custody freezes outbound flow for that asset without a human in the loop — and clears itself only when drift returns inside the alert threshold. There is no third accounting view to cross-check against: the ledger is internal to Postgres, so the diff has exactly two sides.
External provider dependency matrix
Derived from the provider registry (lib/providers/index.ts), which resolves
nine integrations at boot, plus observability, which is wired through the
environment rather than the registry. A misconfigured provider throws at boot
rather than failing later on a money path — an environment that cannot resolve
one of these refuses to start. Two of those nine — Chain and Risk — have
no vendor of their own and are folded into the Fireblocks and Elliptic rows
below rather than listed separately; see the note under the table. AWS and
GitHub sit outside the registry entirely — infrastructure and delivery
dependencies, not lib/providers abstractions — and are listed at the bottom
of the same table for the same reason the rest are here: every hard
dependency belongs in one place. A consolidated systems/ownership register
(not just this integration detail) is 24-IT-Systems.
Integration surface
| Provider | Interface | What the platform calls | Auth, outbound | Inbound webhook |
|---|---|---|---|---|
| Fireblocks | FireblocksProvider |
createSubVault, createExternalWallet, addExternalWalletAsset, getExternalWallet, removeExternalWalletAsset, createTransaction, getTransaction, getTransactionByExternalId, estimateNetworkFee, getVaultBalanceMinor; address format checks are in-app (lib/payouts/address-format.ts) — see note below |
API key + RSA request signing; key from Secrets Manager, on disk 0600 |
/api/webhooks/fireblocks — detached JWS, RSA-SHA512 verified against Fireblocks JWKS |
| Sumsub | KycProvider |
generateAccessToken, generateShareToken, getApplicantData, getStatus, resetUserKyc |
HMAC-SHA256 per request, over timestamp + method + path + body | /api/webhooks/kyc — HMAC-SHA256 over the raw body |
| Mercuryo | none — hosted widget (lib/providers/mercuryo.ts) |
signed widget URL (mercuryoCheckoutUrl); no server-to-server call |
widget secret signs the URL | /api/webhooks/mercuryo — HMAC-SHA256, x-signature |
| Elliptic | EllipticProvider |
getRiskScore(address, network, currency), isSanctioned(address) |
API key | — |
| CEX.IO | OracleProvider |
getRate, getRateHistory |
Public REST, unauthenticated | — |
| Resend | EmailProvider |
send(message) |
API key | — |
| Better Stack | not in the registry — observability, wired via environment | log + OTLP trace ingest, worker and web heartbeats | Ingest tokens | — |
| AWS | not in the registry — infrastructure substrate | EC2 app/worker runtime, RDS Postgres, ECR image pulls, S3 (internal buckets), Secrets Manager reads, KMS decrypt, CloudWatch log/metric writes, WAF/ALB edge routing, Route 53 DNS | IAM instance role + per-service IAM policy; no static AWS access keys in the running app | — |
| GitHub | not in the registry — source control + CI/CD | Actions: test on every PR; on master, build the image, push to ECR, assume the deploy role, roll the instance |
GitHub OIDC → short-lived AWS role assumption (token.actions.githubusercontent.com); no long-lived AWS keys stored in GitHub |
— |
Chain is Fireblocks, architecturally
A ChainProvider (address validation, fee estimation, broadcast, on-chain
lookups) had no vendor of its own and did not need a vendor search:
that surface is Fireblocks' job — custody already owns the
wallet/address/fee/broadcast relationship — so it folds into the
Fireblocks row above rather than standing alone. The interface, its
mock and CHAIN_MODE were removed in 2026-11; address format checks are
lib/payouts/address-format.ts.
The equivalent Risk interface is gone. Real-time sanctions and
score checks at payee whitelisting and single-send time now go through
Elliptic directly: screenAddress in lib/aml calls
elliptic.isSanctioned and then lookupAml, so those two paths share
the same 24-hour aml_records cache as the bulk-payout pipeline. There
is no RiskProvider, no MockRisk and no RISK_MODE.
Where each one is consumed
| Provider | Consumed by |
|---|---|
| Fireblocks | Deposit addresses, payee whitelisting, send and payout execution, network-fee estimation, reconciliation balances, the 5-minute pending poll; payee address format checks run in-app first (lib/payouts/address-format.ts, from lib/payees/whitelist.ts) |
| Sumsub | Onboarding, and the ensureKycApproved gate in front of send and payout |
| Mercuryo | Buy quote and charge, sell, settlement callbacks |
| Elliptic | Batch AML scoring for payout rows, cached 24 h, block threshold enforced before execution; also per-address sanctions and score checks on payee whitelisting and single sends, via screenAddress in lib/aml against the same cache |
| CEX.IO | Convert quotes, fee-asset conversion (gas asset → payout asset), rate history |
| Resend | Auth mail, notifications, payout start/complete, invoice reminders — all via the mail queue |
| Better Stack | Structured logs, traces, uptime monitors, worker heartbeat, public status page |
| AWS | Every component in 17-System-Design and 22-Environment-Resources — the entire runtime, network and data tier |
| GitHub | Source of truth for the codebase; Actions drives the tag/branch-triggered release (AWS Deployment and First Production Deployment references, internal) |
Configuration and failure behaviour
Mode is per provider — <NAME>_MODE, falling back to PROVIDER_MODE. The
failure column is the part worth reviewing: an integration that degrades
loudly is a different risk from one that degrades silently.
| Provider | Mode var | Other configuration | When it is unavailable |
|---|---|---|---|
| Fireblocks | FIREBLOCKS_MODE |
FIREBLOCKS_API_KEY, private key via FIREBLOCKS_PRIVATE_KEY_SECRET_ARN / _PATH / _PEM, FIREBLOCKS_PARENT_VAULT_ID, FIREBLOCKS_WEBHOOK_JWKS_URL |
Degrades safely. A failed submit marks the row submission-pending and is retried; a failed status read marks it provider-pending. The 5-minute poll resolves both. Balance failure fails that asset's reconciliation only, not the others. Address validation / fee estimation failing (the Chain surface) blocks whitelisting and quoting rather than letting either through |
| Sumsub | KYC_MODE |
SUMSUB_APP_TOKEN, SUMSUB_SECRET_KEY |
Fails closed. No new verifications; ensureKycApproved keeps refusing until a verdict lands, so value cannot move on an unverified account |
| Mercuryo | MERCURYO_ENABLED |
MERCURYO_ENVIRONMENT, MERCURYO_WIDGET_ID, MERCURYO_WIDGET_SECRET, MERCURYO_WEBHOOK_SIGN_KEY / MERCURYO_SIGN_KEY |
Fails closed. Buy is unavailable. Nothing is credited without a custody-confirmed deposit |
| Elliptic | ELLIPTIC_MODE |
API credentials | Fails closed by design — AmlProviderError names the addresses whose score could not be verified, and the batch does not proceed with unscored rows. The real-time screenAddress check refuses rather than skips at whitelisting and single-send time too |
| CEX.IO | ORACLE_MODE |
— | Convert quotes and fee conversion fail; no cached fallback rate is served as if it were live |
| Resend | EMAIL_PROVIDER, EMAIL_MODE |
RESEND_API_KEY, MAIL_FROM |
Mail is a durable queue job — delivery retries, and nothing financial depends on it |
| Better Stack | — | BETTER_STACK_* ingest URLs and heartbeat URLs |
Observability is lost; the platform keeps running. The worker heartbeat going silent is itself the alert |
Three structural properties of the boundary:
- Every inbound webhook shares one inbox. Signature check at the route, then an idempotent insert keyed on (provider, event id), then pg-boss processing — provider redelivery is always safe, and an unverified payload never reaches business logic.
- Provider mode is per provider. Each integration is selected by its own
<NAME>_MODEvariable, so one can be swapped without touching the others. - Most of these fail closed. Only mail and observability degrade without stopping the flow they belong to; the AML, sanctions, custody and pricing paths all refuse rather than proceed on missing data.
Related
- 17-System-Design — where these flows run, per environment
- 21-Trust-Boundaries — the same system cut by trust, not by flow
- 12-Environments — environment profiles and the DR data flows
- 24-IT-Systems — ownership, criticality and environment presence for every system named above
- 22-Environment-Resources — the infrastructure these flows run on