Conventions & patterns
These are the recurring patterns in the codebase. Follow them when you change the system — most bugs come from breaking one of these.
Prepare → sign → confirm (custody)
On‑chain actions are never signed by the backend. The flow is always:
- Prepare — the service returns the call data / ABI for the action (
POST …/actions/:intent). - Sign — the user signs client‑side via wagmi/Privy.
- Confirm — the client posts the
txHash; the service verifies the transaction on‑chain (right contract, function, sender, args) before mutating the database (POST …/confirm).
The chain is the source of truth. Don't add a code path that updates custody state without an on‑chain verification.
operationHash links off-chain ↔ on-chain
Every cross‑cutting operation carries an operationHash = a UUID encoded as bytes32, byte‑identical to the contract's offChainId. Use the helpers in @axon/db (operationHashFromId) so DB rows and on‑chain events always join. Never generate it ad‑hoc.
Money is BigInt, transported as strings
Use packages/db/src/money.ts for all arithmetic (micro‑USDC BigInt, contract‑parity). Amounts cross service/HTTP boundaries as decimal strings, never JS numbers (ethers.parseUnits throws on float artifacts). feePct, bps, totals — all through money.ts.
Dual‑mode & profile scoping
- The acting profile is sent as
X-AXON-Profile-Id; services validate the token's user owns that profile (mismatch →403). - Resolve the active wallet through
useActiveWallet()(web/mobile) — never hard‑codewallets[0](it isn't HD‑ordered; you'd sign with the wrong key). canSignreflects whether the wallet can sign in this browser; gate signing UI on it.
No cross-service foreign keys
Links across service boundaries are plain string IDs, not Prisma relations (e.g. FundingCoverage.settlementId). Integrity is enforced in code. Don't add an FK across a boundary — it couples deploys.
Idempotency
Side‑effectful ingestion must be idempotent:
- coverage on inbound transfers is keyed on
sourceTxId(unique) — re‑scans are harmless; - settlement recording is idempotent on
source+sourceId; - webhooks dedupe on
WebhookEvent (provider, externalEventId).
Background workers
Workers are plain interval loops started in a module's onModuleInit (e.g. CoverageWatcher 30 s, settlements reconcile 60 s / anchor 30 min, reconciliation 30 s). They are:
- single‑flight (a
runningguard so passes don't stack), - stateless / re‑entrant where possible (wide look‑back windows + idempotency instead of cursors),
- fail‑soft (a failed pass logs and retries next tick; it never crashes the service).
Auth & secrets
- Verify JWTs with
verifyBearer/verifyHs256Jwtfrom@axon/shared-config— services re‑verify even though the gateway already did. - Read config through
getSecret()(fail‑closed in prod). Don't readprocess.envdirectly for secrets. NEXT_PUBLIC_*are build‑time — adding one means rebuilding the frontend image.
Notifications
Notify the counterparty, never the actor who triggered the event. Use the typed NotificationType values.
Reports
PDF/CSV reports go through packages/shared-reports. Endpoints return base64 (PDF) or text (CSV); the gateway passes binary through verbatim (don't JSON‑wrap it).
Gotchas worth knowing
- Prisma CLI lives at
packages/db/node_modules/.bin/prisma(pnpm doesn't hoist it to root). SERVICE_SETTLEMENTS_HOSTmust be correct or cross‑service settlement recording silently fails.- Services compile with
strictNullChecks: false→ discriminated‑union narrowing doesn't work in shared types; prefer optional fields. - The
reconciliationservice mounts health at/api/v1/health(not/api/v1/reconciliation/health). - Legacy
shell+mfe-*are gated behind thelegacy-mfecompose profile (they OOM the default build) and are superseded byapps/web.