NestJS + TypeScript (strict) + TypeORM + PostgreSQL + Redis. One repository builds one Nest application graph with five entry points; they share entities, migrations and helpers, and differ in which modules they load — which is exactly how the trust boundaries are drawn.
How the services connect
There is no message broker. Every process hands work to the next one through rows in PostgreSQL and polls for what it should pick up — which is why the database, not a queue, sits at the centre of the picture. Click any box for what it does; step through the buttons to follow one payment from the till to a minted token.
Click a box for what it holds and what it may write. Dashed lines are reads or out-of-band actions; the paired dots on the worker lines are the poll-and-write loop each one runs against the database.
Five processes, one codebase
| Entry point | Loads | Holds | Loop |
|---|---|---|---|
src/main.ts |
AppModule — auth, users, wallets, coupons, claims,
payments, pricing, indexer
|
no chain key | HTTP + poller / pricing / accrual timers |
src/issuer/main.ts |
IssuerModule |
one ISSUER_ROLE key |
attest pending claims |
src/relayer/main.ts |
RelayerModule |
the only chain-writing key | submit claim() |
src/settlements/main.ts |
SettlementsModule |
nothing | read Claimed, reconcile |
src/monitor/main.ts |
MonitorModule |
guardian (PAUSER_ROLE) |
reconcile, alert, pause() |
Only src/main.ts calls NestFactory.create. The
other four use createApplicationContext: the processes
holding keys have no inbound HTTP surface at all.
Two module-graph rules are asserted by tests rather than left to review
(issuer-independence.spec.ts,
relayer-isolation.spec.ts):
-
neither the issuer nor the relayer may reach
IndexerModuleorPaymentsModule— a transitive import would start a second poller against the shared indexer budget and put the client oneinject()away from the layer built to distrust it; -
neither may declare a controller, and the relayer may not write
attestations.
In production: one image, five deployments
The npm run scripts are ts-node over
src/, and the production image has neither —
nest build produces a webpack bundle with a single entry, and
npm ci --omit=dev drops ts-node. So
src/main.ts dispatches on PROCESS_ROLE instead,
and every role ships as the same image.
PROCESS_ROLE | Boots | HTTP |
|---|---|---|
api (default) | API + poller + pricing + accrual | port 3000 |
issuer | attestation loop | none |
relayer | claim() submission | none |
settlement | Claimed event watcher | none |
monitor | supply / epoch / health checks, guardian key | none |
An unrecognised value fails fast rather than silently booting the API.
Only the API should run migrations (migrationsRun: true); the
workers attach to the schema it made.
Module map
src/
├── main.ts HTTP entry point
├── app.module.ts API composition root
│
├── auth/ Google id_token → our JWT; JwtStrategy + guard
├── users/ user records, identity is the IdP `sub`
├── wallets/ address linking (declaration), address normalisation
├── coupons/ coupon read API + accrual loop (5 % of a payment)
├── pricing/ one canonical USD snapshot per payment
├── payments/ merchant registry, indexer polling, confirmation policy
├── indexer/ the single HTTP client for the WDK Indexer API
├── claims/ claim challenge, claim creation, claim state machine
├── attestations/ issuer signatures over an entitlement
├── settlements/ `Claimed` watcher process + settlement records
├── issuer/ issuer process: verify, price-check, sign
├── relayer/ relayer process: preflight, nonce queue, submit
├── monitor/ monitor process: reconcile, alert, pause
├── signers/ registry of issuer/relayer/guardian addresses
├── transactions/ history table (device- and poller-written)
├── idempotency/ replay protection for POST /claims
├── chains/ the chain registry and `paymentRef` derivation
├── config/ Zod env schema, GET /config
├── database/ entity registry, migrations, seed
└── common/
├── alerts/ one alert type; every alert names a subject
├── chain/ payment verifier (own-node), event cursors
├── crypto/ Argon2id + AES-256-GCM secret box
├── signing/ WDK signer, `enc:` / `env:` / `kms:` key refs
└── metrics/ counters the monitor reads across processes
Data model
Seventeen tables, grouped by what they are for.
Identity and wallets
| Table | Carries | Invariant that matters |
|---|---|---|
users |
IdP subject → user |
UNIQUE (externalAuthId). Email is display-only: not
unique, not an identity
|
wallets |
one address per chain per user |
UNIQUE (chain, address),
UNIQUE (userId, chain), one primary per user
(partial unique index)
|
wallet_secrets |
client-encrypted entropy / seed |
opaque ciphertext + free-form metadata, append-only
list per user and kind
|
claim_challenges |
single-use nonce for the claim screen | UNIQUE (nonce), consumed once, five-minute TTL |
The money path
| Table | Carries | Invariant that matters |
|---|---|---|
merchants | registered payee addresses | UNIQUE (srcChainId, address) |
indexer_cursors | poll position per chain/token/merchant | last block / tx / transfer index — ordering, not timestamps |
payments | observed merchant transfers | UNIQUE (srcChainId, txHash, outputIndex), plus the derived paymentRef |
price_snapshots | one canonical USD price per payment | UNIQUE (paymentRef) and an append-only trigger — no UPDATE, no DELETE |
coupons | 5 % cashback, one per payment | UNIQUE (paymentRef), UNIQUE (code), state machine enforced by trigger |
claims | one claim per coupon | UNIQUE (coupon_id), state machine trigger, FAILED iff a reason is set |
attestations | issuer signatures | UNIQUE (claim_id, issuer_address) — the DB mirror of the contract's strict-ascending signer check |
settlements | on-chain Claimed events | UNIQUE (payment_ref) |
idempotency_keys | replay protection | UNIQUE (user_id, idempotency_key) |
Operational
signers (addresses of issuer / relayer / guardian — never
keys), event_cursors (how far a chain-event reader has read),
service_counters (indexer request / error / 429 counts the
monitor reads from another process), transactions (history
the app pages through).
Two rules the schema encodes rather than the code:
-
amounts are
NUMERIC(78, 0)in the smallest unit. 78 digits coversuint256;bigintoverflows on 18-decimal tokens, and floats have no business near money. -
paymentRef = keccak256(abi.encode(srcChainId, txHash, outputIndex))is the same value on-chain and off: the contract's nullifier and our dedup key are literally the same bytes.
State machines
Both are enforced by database triggers, so a service that gets its state handling wrong gets an error rather than a row a later payout reads as authorisation.
coupon: PENDING ──▶ ISSUED ──▶ PENDING_ATTESTATION ──▶ ATTESTED
▲ │ │
└──────────────┴────────────────────┴──▶ (released)
▼
CLAIM_SUBMITTED ──▶ CLAIMED
any ──▶ EXPIRED | ORPHANED
claim: PENDING_ATTESTATION ──▶ ATTESTED ──▶ CLAIM_SUBMITTED ──▶ CLAIMED
│ │ │
▼ ▼ ▼
FAILED FAILED | EXPIRED FAILED
PENDING coupons are a projection, not rows: the coupon list
unions confirmed coupons with the caller's payments that have not reached
confirmation depth, so the app can render “4 / 20 confirmations” instead
of a spinner.
Who moves what: accrual creates ISSUED; the API moves
ISSUED → PENDING_ATTESTATION when a claim is created; the
issuer moves to ATTESTED at K signatures or fails the claim;
the relayer moves to CLAIM_SUBMITTED; the settlement watcher
moves to CLAIMED. Every failure path releases the coupon back
to ISSUED — nothing is left in limbo, and nothing is retried
automatically.
Data flows
Payment → coupon
poller merchants (active) → indexer batch query, cursor-bounded
filter to inbound transfers, ordered by (block, txIndex, transferIndex)
insert payments (paymentRef, status=pending|ignored)
attribute the payer via wallets, matched by chain KIND
pricing for each confirmed payment: one immutable price_snapshots row
accrual confirmed + priced + no coupon yet → coupon (ISSUED, 5 %, code)
The poller trusts the indexer for what it returns and nothing
else: the recipient must be a registered merchant, ordering comes from the
cursor, and a transfer from an address nobody linked is recorded
ignored rather than retried forever. Confirmation depth is
decided by ConfirmationPolicy against our own RPC, never by
the indexer's opinion.
Claim → mint
app GET /claims/challenge?coupon=CODE → nonce + exact message
personal_sign(message)
POST /claims { code, challengeId, signature, Idempotency-Key }
API advisory lock on the user
cooldown check → resolve coupon → resolve recipient (primary EVM)
consume challenge, recover signer, require == recipient
mark the wallet verified (taking it from a squatter if needed)
UPDATE coupons SET status=PENDING_ATTESTATION WHERE status=ISSUED ← the check
INSERT claims (amount and paymentRef copied verbatim)
issuer re-derive paymentRef; read the receipt from ITS OWN node; check block,
reorg, depth, log index, token contract, merchant, payer, amount;
validate the snapshot against its own price provider (±1 %, time window);
recompute the 5 % and compare; re-check recipient and rate limit;
sign EIP-712 Entitlement → attestations
relayer verify the payment against ITS OWN node; preflight everything the
contract would check (paused, nullifier, caps, deadline, signer roles,
strict ascending order); estimate gas; sign; submit with a sequential
nonce from a serialised queue; wait for the receipt
watcher read Claimed logs → settlements + claim CLAIMED
unknown paymentRef → CRITICAL alert (a mint outside the pipeline)
submitted-but-never-settled past deadline → release the coupon
Failure paths
An issuer that disagrees writes the reason, raises an alert and fails the
claim (ATTESTATION_REJECTED); a relayer that cannot submit
does the same (SUBMISSION_FAILED) before spending
gas. Neither retries: a refusal is a statement about the payment, and
asking again until it says yes is precisely the wrong response.
REST API surface
Everything below is implemented, under the /api prefix.
Swagger UI at /docs, OpenAPI JSON at /docs/json.
| Method | Path | Purpose |
|---|---|---|
| GET | /health | liveness + database connectivity (deliberately outside the /api prefix, where load balancers look) |
| GET | /config | cashback rate, UTL rate, confirmation depths |
| POST | /auth/google | Google idToken → our JWT pair |
| POST | /auth/refresh | refresh → new pair |
| POST | /auth/dev/test-token | development only, after npm run seed |
| GET | /users/me | current user, wallet mapping, which blobs are stored |
| POST | /wallets | link every address derived from the mnemonic, in one call |
| GET | /wallets | the user's linked addresses |
| POST | /secrets/entropy | store an encrypted entropy blob (opaque string, 204) |
| GET | /secrets/entropy | { entropies: [{ entropy, metadata? }] } for a restore |
| POST | /secrets/seed | store an encrypted seed blob (derived cache, 204) |
| GET | /secrets/seed | { seeds: [{ seed, metadata? }] } |
| GET | /merchants | addresses whose incoming transfers earn cashback |
| GET | /merchants/:id | one merchant |
| POST | /merchants | register one (x-admin-key, not a user token) |
| GET | /coupons | coupon list; PENDING rows carry a live confirmation count |
| GET | /coupons/:id | one coupon |
| GET | /coupons/by-code/:code | resolve a manually typed code |
| GET | /claims/challenge?coupon= | nonce + the exact message to sign |
| POST | /claims | claim one coupon (Idempotency-Key required) |
| GET | /claims | the user's claims, ten per page |
| GET | /claims/preview | claimable set, total UTL, cooldown state |
| GET | /claims/:id | poll status and attestation progress |
| GET | /transactions | history, ten per page, keyset cursor |
| POST | /transactions | record what the device just broadcast (Idempotency-Key) |
| GET | /transactions/:id | one transaction, polled until it confirms or fails |
| GET | /balances | cached balances with their age; never a synchronous proxy |
| GET | /pricing/live | live asset price |
| GET | /indexer/:blockchain/:token/:address/token-transfers | indexer passthrough (debugging) |
| GET | /indexer/:blockchain/:token/:address/token-balances | indexer passthrough (debugging) |
The merchant registry
The merchants table is the whole subscription list. The
payment poller reads it every tick and asks the indexer for transfers to
those addresses and no others, so an address that is not registered
generates no payments and therefore no coupons. A transfer from a linked
wallet to a registered merchant becomes a payments row, and
once it reaches its chain's confirmation depth the accrual pass mints a
coupon worth CASHBACK_BPS of it — 500 bps, 5 %, by default.
Registration is guarded by ADMIN_API_KEY in the
x-admin-key header rather than a user JWT, because whoever
can register an address can pay it and collect 5 % of their own money
back forever. An unset ADMIN_API_KEY closes the
endpoint rather than opening it.
curl -X POST http://localhost:3000/api/merchants \
-H "x-admin-key: $ADMIN_API_KEY" -H 'content-type: application/json' \
-d '{"name":"Demo Merchant","srcChainId":11155111,"address":"0x…","token":"usdt"}'
Where the signature happens
The user signs once, on the claim screen:
-
POST /wallets, right after the seed phrase exists — a declaration of addresses, no signature. Everything landsverified: false. -
GET /claims/challenge?coupon=CODE→ a single-use nonce and the exactmessageto sign (personal_sign), with the coupon code inside it so one signature cannot be replayed against another coupon. -
POST /claims { code | couponId, challengeId, signature }→ the signature must recover to the user's primary EVM address. That marks the addressverifiedand, if somebody else had merely declared it, takes it from them: a declaration never outranks a proof.
Linking without a signature is a deliberate trade. A linked address earns coupons, but only a signature releases money, so extra prompts during onboarding would buy nothing the claim-time proof does not already guarantee. Someone who links an address they cannot sign for collects coupons they can never claim.
What /secrets/* does and does not hold
Ciphertext, and nothing else. Encryption and decryption happen on the
device; the mnemonic, entropy, seed, passphrase and encryption key never
reach this process. Two blobs, because WDK produces two:
entropy is the source of truth, seed is a
derived cache that can be recomputed.
The blob is an opaque string: no format, no length beyond
the 50 KB body cap, no server-side KDF policy — cipher, KDF and its
parameters are the client's choice and travel in the free-form
metadata object the server stores verbatim. Writes append, so
a user can hold several blobs per kind (multi-wallet, rotation), and a read
returns the list. There is no delete route: blobs go away with the user
(ON DELETE CASCADE). Reads carry no per-route rate limit —
only the global throttler — but every read is logged whether or not it hit,
a run of misses being the shape worth alerting on.
Storing a seed server-side is a client requirement, not a recommendation: the client-side KDF and the passphrase behind it are the only thing between a stolen database and every user's funds, and this server does not enforce either.
Concurrency and correctness
The interesting parts of this backend are the places where two things happen at once. Each is handled by the database rather than by careful ordering in code.
| Race | Handled by |
|---|---|
| Two claims for one coupon | UPDATE coupons … WHERE status = 'ISSUED' RETURNING id — the state change is the check — plus UNIQUE (coupon_id) on claims |
Retried POST /claims | INSERT … ON CONFLICT DO NOTHING on idempotency_keys inside the same transaction as the work; the loser reads the winner's stored response |
| Two claims by one user inside the cooldown | pg_advisory_xact_lock(hashtext(userId)) for the rest of the transaction |
| One issuer signing twice | UNIQUE (claim_id, issuer_address), mirroring the contract's ordering rule |
| Two relayer transactions with the same nonce | NonceManagerService: one in-flight submission, nonce from a local counter, resync from the chain on failure |
| Overlapping poll windows | UNIQUE (srcChainId, txHash, outputIndex); a duplicate is a no-op update of lastSeenAt |
| Re-reading a block range after a restart | UNIQUE (payment_ref) on settlements |
| Illegal state transitions | coupons_state_machine / claims_state_machine triggers |
Error handling and alerts
HTTP errors are shaped by GlobalExceptionFilter into
{ error: { code, message, details? } } with codes from
src/common/enums/error-codes.enum.ts. Ownership questions
answer 404, never 403: a 403 on a
coupon code would confirm that the code exists.
Background failures go through AlertService, whose
IAlert type makes subject mandatory — an alert
that says “reconciliation failed” sends someone hunting; one that names the
claim or the paymentRef sends them to the row. Alerts are
logged as security_event=… lines and optionally POSTed to
ALERT_WEBHOOK_URL; a pager that is down never takes the
process with it. When that URL is a Telegram sendMessage
endpoint and ALERT_TELEGRAM_CHAT_ID is set, the payload is
rewritten into the Bot API's {chat_id, text} shape.
Configuration
src/config/env.ts is the single schema (Zod, fail-fast at
startup); every variable is documented there and mirrored in
.env.example.
| Group | Keys |
|---|---|
| Runtime | NODE_ENV, PORT, APP_NAME, CORS_ORIGINS, LOG_LEVEL |
| Database / Redis | DB_*, REDIS_* |
| Auth | AUTH_PROVIDER, JWT_SECRET, JWT_EXPIRATION, REFRESH_TOKEN_EXPIRATION, AUTH_ISSUER, AUTH_AUDIENCE, JWKS_URI, GOOGLE_*_CLIENT_ID |
| Indexer | INDEXER_BASE_URL, INDEXER_API_KEY, PAYMENT_POLL_* |
| Money | CASHBACK_BPS (500 = 5 %), UTL_USD_RATE, PRICING_*, ACCRUAL_* |
| Merchants | ADMIN_API_KEY, MERCHANT_ADDRESS, MERCHANT_NAME, MERCHANT_SRC_CHAIN_ID, MERCHANT_TOKEN |
| Chains | SUPPORTED_CHAINS, SUPPORTED_ASSETS, CONFIRMATION_DEPTHS, RPC_URLS, RPC_SHARING_ALLOWED_CHAINS, TOKEN_ADDRESSES, REWARD_CHAIN_ID |
| Contracts | COUPON_CLAIM_CONTRACT_ADDRESS, UTILITY_TOKEN_CONTRACT_ADDRESS, UTILITY_TOKEN_CONTRACT_ABI |
| Transactions | TX_OBSERVATION_TIMEOUT_MS, TX_SWEEP_INTERVAL_MS, BALANCE_CACHE_TTL_MS |
| Claims | ATTESTATION_THRESHOLD, CLAIM_COOLDOWN_HOURS, CLAIM_DEADLINE_SECONDS, CLAIM_SWEEP_INTERVAL_MS |
| Issuer | ISSUER_ID, ISSUER_RPC_URLS, ISSUER_SIGNING_KEY, ISSUER_PRICE_PROVIDER, PRICE_TOLERANCE_BPS, PRICE_WINDOW_SECONDS |
| Relayer | RELAYER_RPC_URLS, RELAYER_SIGNING_KEY, RELAYER_CONFIRMATIONS, RELAYER_MAX_FEE_GWEI, RELAYER_DEADLINE_MARGIN_SECONDS |
| Watcher / monitor | SETTLEMENT_*, MONITOR_*, ALERT_WEBHOOK_URL, ALERT_TELEGRAM_CHAT_ID |
| Keys | SIGNER_KEY_PASSWORD, SEED_BACKUP_ENCRYPTION_KEY |
Two rules the code enforces at startup rather than in review:
-
*_RPC_URLSis a map keyed bysrcChainId. Payments are on mainnet and rewards on Sepolia, so a verifier needs a node for the chain the payment is on. A chain with no endpoint is refused (NO_NODE) rather than asked of the wrong node. -
No two processes may share an RPC endpoint. Two
verifiers behind one provider are one verifier wearing two hats, so the
process refuses to start.
RPC_SHARING_ALLOWED_CHAINS(empty by default) lists the chains where that is waived — Tron and Bitcoin have effectively one free public API each.
SEED_BACKUP_ENCRYPTION_KEY is a leftover from an earlier
design where the server encrypted the seed backup itself. Nothing reads
it — /secrets/* takes ciphertext only — but the Zod schema
still requires it, so a boot fails without it. Drop it from the schema
and from .env.example together.
Key management
Signing keys are never in the database and never in plaintext:
ISSUER_SIGNING_KEY=enc:argon2id$m=65536,t=3,p=1$<salt>$<iv||ciphertext||tag>
enc: is opened with SIGNER_KEY_PASSWORD (32
random bytes) using Argon2id (m=65536, t=3, p=1).
env:0x… is refused outside development;
kms:<arn> is the production shape and is deliberately
not implemented rather than faked — a stub that signed
locally would make an insecure deployment look secure. Signing goes through
@tetherto/wdk-wallet-evm, so backend and wallet share one
implementation.
The signers table holds addresses only
(issuer, relayer, guardian), and each process refuses to start unless its
own address is an active row there. Because the production image has no
src/ and no ts-node, signers ship as reference
data in the SeedSigners migration rather than through
npm run seed (which stays a local dev tool gated on
NODE_ENV === 'development').
Testing
590 tests across 54 suites: 94 % of statements, 95 % of lines,
95 % of functions, 85 % of branches. jest.config.js
gates the build just under those numbers, so coverage cannot be given back
by accident. Branches sit lower on purpose — what is left is mostly
chain-error handling, where a faithful test needs a real node rather than
another mock asserting the mock was called.
Three kinds of test matter more than the count:
-
Cross-repo drift.
payment-ref.spec.tsandentitlement.spec.tsread the committed fixtures from the contracts repo directly and assert that TypeScript and Solidity produce the samepaymentRefand the same EIP-712 digest. -
Golden amounts. Accrual and the issuer must both
reproduce
test/fixtures/accrual/golden-amounts.jsonbyte for byte — a different amount is a different digest, and no signature would reach the threshold. - Isolation guards. Tests walk the issuer's and relayer's module graphs and fail if the indexer client, a controller, or an attestation write turns up where it must not.
WDK's ESM-only signer cannot be imported by Jest's CommonJS runtime, so the
real signing path is exercised by npm run verify:signer under
plain node instead. Migrations are verified against a real Postgres:
migration:generate must report no changes on a clean database.
Code conventions
Full rules live in backend/CLAUDE.md. The load-bearing ones:
- Files kebab-case; classes PascalCase; interfaces
I-prefixed in their own*.interface.ts; enumsE-prefixed. - Error codes centralised in
src/common/enums/error-codes.enum.ts; never a hardcoded string. - Entity properties camelCase, columns/tables snake_case via explicit
name, indexesIDX_table_column. - Layering per module: controller (HTTP only) → service (business logic) → repo (extends
TypeOrmBaseRepo), with a mapper for DTO/entity transforms. - DTO properties snake_case with class-validator +
@ApiProperty; validation only at trust boundaries. - Domain-specific exceptions, never a generic
Error. - No
synchronize: true, noconsole.log, noany, no hardcoded config. - Imports ordered external →
@/aliases → relative.
Known limitations
-
Migrations run at process start (
migrationsRun: true): fine for one instance, a race for two. -
Fixed epoch windows in the contract mean up to 2×
epochCapcan mint across a boundary; bounded deliberately. - Non-EVM payments (Bitcoin, Tron, Spark) are ingested but cannot be verified — no issuer has a node of that kind, so they are refused rather than guessed at.
- No queue: services hand work to each other through DB rows and polling. That is a latency and throughput ceiling, not a correctness one.
-
Not built yet:
POST /auth/session/GET /meas named in the endpoint reference (covered today by/auth/googleand/users/me), Helmet and request correlation ids, and integration tests for the raw SQL paths (the coupon listUNION ALL, the idempotencyON CONFLICT).