vault
03 — Domains
Product Domains
Route note: authenticated retail + merchant surfaces are served under
/my/*. Earlier revisions of this page used bare paths (/wallet,/send, …) — those no longer exist.
Auth & Sessions 🟢 Live
- App-local NextAuth v5 credentials: password (scrypt), TOTP, passkeys (WebAuthn), magic link, recovery codes — all app-owned.
- Enrolment is gated:
/setup/password,/setup/totp,/setup/passkey,/setup/recovery,/setup/kyc(seelib/auth/setup-gate.ts,lib/auth/kyc-gate.ts). - Self-service recovery:
/reset/password,/reset/totp,/reset/sessions/[token]. - Web session: JWT cookie, revocable via
users.session_version+auth_session_revocation_tokens. - Known-device detection (
auth_known_devices) and DB-backed login lockout (rate_limits,lib/rate-limit/lockout.ts). - API: bearer / x-api-key against
api_keys(sha256, revocable, with expiry). - Admins are users with persisted
users.role = 'admin'. - Per-user feature flags gate product surfaces:
orders_enabled,payouts_enabled,invoices_enabled(lib/auth/features.ts).
Wallet & Ledger 🟢 Live
- Per-(user, asset) ledger; balance is
SUM(credits) - SUM(debits). pg_advisory_xact_lockper(account, asset)to serialise mutations.- Wallet history paginates over
transactions; merchants can filter byinvoice+payout. - See 04-Data Model#ledger_entries.
Fees 🟢 Live
The tier model is gone. The fee_tiers table and the user/silver/gold/platinum tier keys were removed, along with users.tier, resolveUserFeeTier and resolveOpFeeTier. Fees are now per-merchant rates on users plus a small set of global knobs in platform_fees.
- Per-merchant rates, stored directly on
users:invoice_pct,payout_pct,api_pct,receive_pct,sell_pct,convert_pct, and the fixedinvoice_open_fee_eur_minor. Resolved byresolveMerchantOpRate(lib/fees). - Global knobs in
platform_fees(one named row per knob,value_eur_minororvalue_pct):send_eur_minor— fixed send fee in EUR cents (default €5), viaresolveSendFeeEurMinor.buy_external_pct— on-ramp reconciliation rate (default 1.5%), viaresolveBuyExternalRate.payout_max_recipients— admin-tunable ceiling on batch size, under the hard limit inlib/payouts/limits.ts.
- Quote helpers per surface:
calcSendQuote,calcSellQuote,calcConvertQuote,calcBuyQuote,calcOrderQuote,calcInvoiceQuote,calcPayoutQuote. - Editable from
/admin/fees(global knobs) and/admin/users/[id](per-merchant rates). Audit log on every change.
Rate changes apply
prospectively only — persisted fee amounts on open intents are not re-quoted. See 08-Risks and Drawbacks#Fees.
Invoices 🟢 Live
- States:
draft → opened → (paid | overpaid | underpaid | due | void). - On
opened, allocate a Fireblocks sub-vault per invoice → unique deposit address. - Platform fee added on top of subtotal at create time (
platform_fee_minorpersisted). - Partial payments: monitor accumulates confirmed deposits; status flips to
paid/overpaidwhen cumulative crosses tolerance. - Top-up flow: public invoice page shows the residual until fully paid.
Pay-to 🟢 Live
- Public link
/pay/to/<slug>resolves slug → wallet owner. - Optional email confirmation handshake (
payment_email_confirmations, 1 h TTL) at/pay/to/confirm/[token]. /pay/to/<paymentId>page with QR + dev-onlyemulate paymentbutton. (UUID vs slug is dispatched inside the unified[idOrSlug]route.)- Amountless pay-to intents are supported (
0041_payment_intents_pay_to_amountless). - Admins can rotate a leaked
public_slug(adminWalletsRouter.rotateSlug); the old slug 404s immediately. - On simulated payment: order flips
paid;transactionsrow created (type='receive',actorUserId=slug owner); ledger credit; audit row.
Send 🟢 Live
- Quote: tier-rate fee on the principal + chain network fee from
ChainProvider. - Individual outbound network-fee commission is still open and must not be copied onto Mass Payouts by default. See 09-Open Questions#Individual outbound transfer network-fee commission.
- Token sends may require a different gas asset than the asset being sent (for example
USDT_ETHrequires ETH gas). See 09-Open Questions#Gas asset funding for token transfers. - Server action locks
(user, asset), debits, createstransactionsrow, then enqueuessend.submittedon pg-boss. - Sends resolve an active Fireblocks external wallet from
payee_walletsand re-resolve it immediately before broadcast; a changed address or wallet identity aborts the send. One-time addresses are dev-tooling only — see implementation/fireblocks-whitelisted-wallets. - Non-whitelisted destinations require risk score < 5.
Receive 🟢 Live
/my/receive/<asset>shows the user's deterministic deposit address + QR.- Incoming deposits are normalized from provider webhooks into
observed_deposits; dev emulation queues a Fireblocks-style callback through pg-boss so the same ingestion path runs locally.
Buy 🟢 Live (mocked acquirer)
- Quote: oracle rate, tier-rate fee.
- Card path:
/pay/card?orderId=...with mocked test cards (4242 ...settles, others decline). - Buy fee is informational — actual fee set by the external on-ramp; we record
metadata.expectedFeefor reconciliation.
Sell 🟡 Beta (no fiat rail)
- Crypto leg debits immediately;
transactionsrow recorded withstatus='pending',metadata.payout='pending-fiat-rail'. - The fiat side does not move yet — see 09-Open Questions#Sell payout rail.
Convert 🟢 Live
- Bridges via EUR using BigInt math (no float precision loss for ETH-scale amounts).
- Acquires
(user, fromAsset)and(user, toAsset)locks in sorted order to avoid deadlocks. - One
transactionsrow of typeconvert; two ledger entries (debit + credit).
Payouts 🟢 Live
- Bearer / API-key auth on three POST endpoints + a GET.
- Idempotent on
(user, merchant_batch_id). - AML cache (
aml_records) with 24 h freshness; cache-aside vialib/aml#lookupAml. - Format-validate before AML lookup so we don't pay Elliptic for malformed addresses.
- Funds preflight: trims highest-rowNum ready rows to
insufficient_balanceuntil the batch fits the merchant balance (coversamount + network fee + platform fee). - Per-row execution: lock + balance re-check + ledger debit + row flip +
transactionsmirror +audit_logevent, all in one DB transaction. - 1% mocked Fireblocks failure exercises the
partial_successpath. - Web UI at
/my/payouts/newwith CSV in-browser parse, threshold slider, live totals, "show filtered out" toggle. - Detail page: pagination 100/page; blocked rows hidden by default.
- Execution is queued, not synchronous — the REST endpoint and the merchant UI both call
executePayoutBatch, which dispatches to thepayout.executepg-boss queue. - Batch funds are reserved as a hold up front; row failures refund against the hold and the remainder is released when the batch settles (
lib/payouts/hold.ts). - Recipient count is capped by
platform_fees.payout_max_recipients(admin-tunable, default 100) under a hard ceiling inlib/payouts/limits.ts. - Gated by the
payouts_enabledfeature flag, KYC status, and per-asset outbound halts — a halted asset returns503 halted. - Every ready address must resolve to an
activepayee-wallet whitelist entry; unmatched rows are blocked.
Full engineering reference: reference/payouts — endpoints, lifecycle, fee model, AML cache, concurrency.
Portfolio 🟢 Live (presentation)
- Per-user / per-merchant balance + value summary derived from
ledger_entries, not a raw provider mirror. - Same source of truth as Wallet history; rendered as a value-oriented summary (asset balances, EUR-equivalent, allocation breakdown).
- Distinguishes user-facing balance presentation from the planned omnibus admin Wallets view (06-Admin Console#Planned).
- Accounting interpretation will move to TRES under 11-Target-Architecture#Why TRES (not just Postgres); the user-facing Portfolio surface stays the same.
KYC / KYB 🟢 Live (Sumsub)
KycProviderhas a live Sumsub implementation (lib/providers/kyc/sumsub.ts) selected byKYC_MODE=live;MockKycServiceremains the default.kyc_recordsplususers.kyc_status/kyc_applicant_id/kyc_review_result; company accounts carrykyb_status,registration_number,vat_number.- Inbound applicant-review callbacks land on
/api/webhooks/kyc. /setup/kycis the enrolment surface;lib/auth/kyc-gate.tsblocks sensitive actions until KYC clears.- Admin override at
/admin/users/[id], audited.
The 09-Open Questions#KYC vendor entry predates this — Sumsub is the built integration.
Notifications 🟢 Live
notificationstable (title, body,read_at) per user, dispatched bylib/notifications/dispatch.ts.- Live delivery over Server-Sent Events at
/api/notifications/stream(lib/notifications/listener.ts). - User surface at
/my/notifications. - ⚠️ No per-user preference/opt-out model yet — still tracked at 06-Admin Console#Planned.
Reconciliation & Outbound Halts 🟢 Live
reconciliation_runsrecords ledger-vs-custody comparison runs, driven by thereconcile.balancespg-boss queue (lib/reconciliation/run.ts).- When drift exceeds the hard threshold, the runner opens an outbound halt for that asset (
outbound_halts) — a circuit breaker that pauses sends and payout execution for the asset until an admin or a passing reconcile clears it. A partial unique index enforces at most one active halt per asset. - Operator surface at
/admin/reconciliation; TRES-facing export shape in providers-specs/tres-fee-recording. - Detail: PRD/reference/reconciliation.
Payee Wallet Whitelist 🟢 Live
payee_walletsowns the destination lifecycle (draft → pending → active) independently of contact details inpayees.- Only
activeentries are sendable; Fireblocks Admin Quorum approval movespending → active, synchronised through the durable provider-webhook inbox. - Both normal sends and bulk payouts resolve against the whitelist. Full spec: implementation/fireblocks-whitelisted-wallets.
CMS Pages & FAQ 🟢 Live
cms_pages(slug, markdown body, category,published, meta description) rendered at the public/[slug]route; managed at/admin/pages.faq_articleswith audience targeting (faq_audienceenum); managed at/admin/faq, surfaced at/my/help.- ⚠️ The policy-version consent popup described in the original brief is not built — only page authoring is.
Subaccounts 🔴 Spec
Not built. A UI prototype exists under
components/v2/views/subaccounts-view.tsx+components/v2/access-groups.tsrunning on mock data, but there is no subaccount table, no invitation flow and no permission enforcement in the database or server layer. Gated by 10-Roadmap#Beta.
- Merchant invites a colleague by name + email.
- Merchant assigns allowed sections (e.g. Invoices read-only, Payouts disabled, API enabled).
- Daxchain emails the invitation; colleague clicks the link, sets a password, lands on the merchant's account scoped to assigned sections.
- Subaccounts must not bypass merchant-level access restrictions: a subaccount cannot exceed the parent merchant's permissions.
- Admin-side review surface tracked at 06-Admin Console#Planned.
- Audit: every grant + revocation lands in
audit_logper 14-Operations#Privileged actions.
Open question: revocation propagation when the parent merchant is disabled — see 09-Open Questions#Subaccount revocation.
Admin Console 🟢 Live
- See 06-Admin Console for the full surface.