vault

03 — Domains

docs/03-Domains.mdupdated: 2026-08-09

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 (see lib/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_lock per (account, asset) to serialise mutations.
  • Wallet history paginates over transactions; merchants can filter by invoice + 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 fixed invoice_open_fee_eur_minor. Resolved by resolveMerchantOpRate (lib/fees).
  • Global knobs in platform_fees (one named row per knob, value_eur_minor or value_pct):
    • send_eur_minor — fixed send fee in EUR cents (default €5), via resolveSendFeeEurMinor.
    • buy_external_pct — on-ramp reconciliation rate (default 1.5%), via resolveBuyExternalRate.
    • payout_max_recipients — admin-tunable ceiling on batch size, under the hard limit in lib/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_minor persisted).
  • Partial payments: monitor accumulates confirmed deposits; status flips to paid/overpaid when 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-only emulate payment button. (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; transactions row 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_ETH requires ETH gas). See 09-Open Questions#Gas asset funding for token transfers.
  • Server action locks (user, asset), debits, creates transactions row, then enqueues send.submitted on pg-boss.
  • Sends resolve an active Fireblocks external wallet from payee_wallets and 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.expectedFee for reconciliation.

Sell 🟡 Beta (no fiat rail)

  • Crypto leg debits immediately; transactions row recorded with status='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 transactions row of type convert; 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 via lib/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_balance until the batch fits the merchant balance (covers amount + network fee + platform fee).
  • Per-row execution: lock + balance re-check + ledger debit + row flip + transactions mirror + audit_log event, all in one DB transaction.
  • 1% mocked Fireblocks failure exercises the partial_success path.
  • Web UI at /my/payouts/new with 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 the payout.execute pg-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 in lib/payouts/limits.ts.
  • Gated by the payouts_enabled feature flag, KYC status, and per-asset outbound halts — a halted asset returns 503 halted.
  • Every ready address must resolve to an active payee-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)

  • KycProvider has a live Sumsub implementation (lib/providers/kyc/sumsub.ts) selected by KYC_MODE=live; MockKycService remains the default.
  • kyc_records plus users.kyc_status / kyc_applicant_id / kyc_review_result; company accounts carry kyb_status, registration_number, vat_number.
  • Inbound applicant-review callbacks land on /api/webhooks/kyc.
  • /setup/kyc is the enrolment surface; lib/auth/kyc-gate.ts blocks 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

  • notifications table (title, body, read_at) per user, dispatched by lib/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_runs records ledger-vs-custody comparison runs, driven by the reconcile.balances pg-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_wallets owns the destination lifecycle (draft → pending → active) independently of contact details in payees.
  • Only active entries are sendable; Fireblocks Admin Quorum approval moves pending → 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_articles with audience targeting (faq_audience enum); 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.ts running 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_log per 14-Operations#Privileged actions.

Open question: revocation propagation when the parent merchant is disabled — see 09-Open Questions#Subaccount revocation.

Admin Console 🟢 Live

Drawbacks

Open Questions