▸ prd.vault

vault

20 — Data Flows

docs/20-Data-Flows.mdtype: architectureupdated: 2026-11-04

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:

  1. 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.
  2. 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.
  3. 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

flowchart TB subgraph actors["Actors"] user["Retail user / merchant<br/>browser"] api["Merchant integration<br/>REST client"] prov["Providers<br/>webhook senders"] end subgraph edge["Edge"] waf["WAF + ALB<br/>TLS terminated"] end subgraph app["Application tier — web container"] web["Next.js<br/>pages · oRPC · /api/v1 REST · webhook inboxes"] end subgraph data["Data tier — the only datastore"] pg[("PostgreSQL<br/>ledger · queues · sessions · rate limits · webhook inbox · audit")] end subgraph work["Worker tier — one instance"] wrk["pg-boss worker<br/>11 queues + 3 cron schedules"] end subgraph ext["External providers"] fb["Fireblocks<br/>custody"] sum["Sumsub<br/>KYC"] cex["CEX.IO<br/>rates"] res["Resend<br/>email"] end user -->|"TLS"| waf api -->|"TLS + API key"| waf prov -->|"TLS + signature"| waf waf --> web web -->|"reads, intent writes,<br/>job enqueue"| pg wrk -->|"job fetch, ledger writes"| pg wrk --> fb wrk --> cex wrk --> res web -->|"access tokens"| sum fb -.->|"webhooks"| prov sum -.->|"webhooks"| prov
Figure 1 — Actors, tiers and stores — every runtime component and which flows pass through it

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.

sequenceDiagram autonumber participant U as Browser participant W as Web (server action) participant S as Sumsub participant I as Webhook inbox participant DB as PostgreSQL U->>W: start verification W->>S: generateAccessToken(userId, level) — HMAC-signed REST S-->>W: short-lived SDK token W-->>U: token for the WebSDK widget U->>S: documents + liveness go DIRECTLY to Sumsub Note over U,S: identity documents never touch the platform S->>I: POST /api/webhooks/kyc — HMAC-SHA256 body signature I->>I: verify signature, zod-parse, dedupe on (provider, event id) I->>S: GET applicant — enrich missing name/country I->>DB: one transaction: inbox row + users.kycStatus + audit row Note over DB: approved only when reviewStatus=completed AND reviewAnswer=GREEN
Figure 2 — KYC onboarding — token issuance, verification and the webhook that lands the verdict (lib/providers/kyc/sumsub.ts)

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.

flowchart TB subgraph inflow["Deposit — value in"] addr["Per-payment deposit address<br/>Fireblocks sub-vault"] fbw["Fireblocks webhook<br/>RSA-SHA512 detached JWS, JWKS keys"] inbox["Webhook inbox<br/>unique (provider, event id)"] od["observed_deposits<br/>seen → confirmed"] credit["Payment intent settles<br/>ledger credit"] end subgraph outflow["Send / payout — value out"] gate["Gates, fail closed:<br/>KYC approved · feature flag · asset not halted"] debit["Debit + hold inside one transaction<br/>advisory lock per (account, asset)"] q["pg-boss job<br/>send.submitted / payout.execute"] fbtx["fireblocks.createTransaction<br/>idempotency key payout:row:id"] conf["Confirmation poll<br/>COMPLETED settles, else fail + refund"] ledger["Ledger settles: fee delta reconciled,<br/>hold remainder released"] end addr --> fbw --> inbox --> od --> credit gate --> debit --> q --> fbtx --> conf --> ledger poll["Cron every 5 min<br/>re-fetches pending transactions"] -.->|"fallback when a webhook is missed"| od poll -.-> conf
Figure 3 — Transfer flows — deposit detection, single send, bulk payout (lib/use-cases/send.ts, lib/payouts/execute.ts, worker/jobs/provider-webhooks.ts)

Details that carry the correctness weight:

  • Payout destinations must be whitelisted. A row is only submitted if its payee wallet is still active and bound to the same Fireblocks external wallet — re-checked at execution time, not just at upload.
  • Double submission is structurally prevented. If createTransaction errors, 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 blocked and 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.

sequenceDiagram autonumber participant U as Browser participant W as Web participant M as Mercuryo participant F as Fireblocks participant DB as PostgreSQL U->>W: createBuyOrder(asset, EUR amount) W->>DB: KYC gate, limits, onramp_checkouts row U->>W: GET /my/buy/mercuryo?orderId=… W-->>U: 303 to the signed widget URL (address, amount, order id) U->>M: pays by card at Mercuryo M->>W: /api/webhooks/mercuryo — HMAC-SHA256 x-signature, durable inbox W->>DB: checkout status recorded, nothing credited M->>F: crypto on chain to the buyer's custody wallet F->>W: /api/webhooks/fireblocks — confirmed deposit W->>DB: one transaction: advisory lock,<br/>ledger credit, transaction row, audit
Figure 4 — Buy — hosted checkout, callback, custody credit (lib/use-cases/buy.ts, lib/use-cases/mercuryo.ts, app/my/buy/mercuryo)

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.

flowchart TB cron["Hourly cron — worker singleton,<br/>one run per asset"] subgraph inputs["Two balances per asset"] lg["Internal ledger<br/>SUM(credits − debits)"] fb["Fireblocks vault<br/>getVaultBalanceMinor"] end net["Net out in-flight payouts:<br/>drift = actual − (expected + pending)"] runrow["reconciliation_runs row<br/>full snapshot, admin dashboard reads it"] subgraph outcomes["Escalation ladder"] ok["Within threshold — info log"] warnx["Over alert threshold — warn log<br/>+ top-5 merchant attribution"] halt["Over hard threshold — outbound HALTED<br/>for the asset, page on-call"] end clear["Drift recovers → halt auto-clears"] block["Payout execution refuses<br/>halted assets, fail closed"] cron --> lg & fb lg --> net fb --> net net --> runrow runrow --> ok runrow --> warnx runrow --> halt halt --> block halt --> clear
Figure 5 — Reconciliation loop — ledger vs custody balance check with an automatic outbound circuit breaker (lib/reconciliation/run.ts)

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>_MODE variable, 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.