Backend

five processes, one codebase

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.

pays a merchant (mainnet) poll · never trusted REST payments · coupons · claims attestations claim status settlements claim() Claimed pause() Mobile app non-custodial, WDK holds the user's keys WDK Indexer API third party · hosted API process poller · pricing · accrual no chain key Issuer × N verify · sign ISSUER_ROLE key Relayer preflight · submit only writing key Watcher reads Claimed no key Monitor guardian · PAUSER CouponClaim → UTL · Ethereum Sepolia K-of-N signatures · per-claim & per-epoch caps PostgreSQL — the only channel between processes 17 tables · state-machine triggers · advisory locks · unique indexes least-privilege roles: the issuer cannot insert coupons, the relayer cannot insert attestations

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):

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_ROLEBootsHTTP
api (default)API + poller + pricing + accrualport 3000
issuerattestation loopnone
relayerclaim() submissionnone
settlementClaimed event watchernone
monitorsupply / epoch / health checks, guardian keynone

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

TableCarriesInvariant 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

TableCarriesInvariant that matters
merchantsregistered payee addressesUNIQUE (srcChainId, address)
indexer_cursorspoll position per chain/token/merchantlast block / tx / transfer index — ordering, not timestamps
paymentsobserved merchant transfersUNIQUE (srcChainId, txHash, outputIndex), plus the derived paymentRef
price_snapshotsone canonical USD price per paymentUNIQUE (paymentRef) and an append-only trigger — no UPDATE, no DELETE
coupons5 % cashback, one per paymentUNIQUE (paymentRef), UNIQUE (code), state machine enforced by trigger
claimsone claim per couponUNIQUE (coupon_id), state machine trigger, FAILED iff a reason is set
attestationsissuer signaturesUNIQUE (claim_id, issuer_address) — the DB mirror of the contract's strict-ascending signer check
settlementson-chain Claimed eventsUNIQUE (payment_ref)
idempotency_keysreplay protectionUNIQUE (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 covers uint256; bigint overflows 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.

MethodPathPurpose
GET/healthliveness + database connectivity (deliberately outside the /api prefix, where load balancers look)
GET/configcashback rate, UTL rate, confirmation depths
POST/auth/googleGoogle idToken → our JWT pair
POST/auth/refreshrefresh → new pair
POST/auth/dev/test-tokendevelopment only, after npm run seed
GET/users/mecurrent user, wallet mapping, which blobs are stored
POST/walletslink every address derived from the mnemonic, in one call
GET/walletsthe user's linked addresses
POST/secrets/entropystore an encrypted entropy blob (opaque string, 204)
GET/secrets/entropy{ entropies: [{ entropy, metadata? }] } for a restore
POST/secrets/seedstore an encrypted seed blob (derived cache, 204)
GET/secrets/seed{ seeds: [{ seed, metadata? }] }
GET/merchantsaddresses whose incoming transfers earn cashback
GET/merchants/:idone merchant
POST/merchantsregister one (x-admin-key, not a user token)
GET/couponscoupon list; PENDING rows carry a live confirmation count
GET/coupons/:idone coupon
GET/coupons/by-code/:coderesolve a manually typed code
GET/claims/challenge?coupon=nonce + the exact message to sign
POST/claimsclaim one coupon (Idempotency-Key required)
GET/claimsthe user's claims, ten per page
GET/claims/previewclaimable set, total UTL, cooldown state
GET/claims/:idpoll status and attestation progress
GET/transactionshistory, ten per page, keyset cursor
POST/transactionsrecord what the device just broadcast (Idempotency-Key)
GET/transactions/:idone transaction, polled until it confirms or fails
GET/balancescached balances with their age; never a synchronous proxy
GET/pricing/livelive asset price
GET/indexer/:blockchain/:token/:address/token-transfersindexer passthrough (debugging)
GET/indexer/:blockchain/:token/:address/token-balancesindexer 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:

  1. POST /wallets, right after the seed phrase exists — a declaration of addresses, no signature. Everything lands verified: false.
  2. GET /claims/challenge?coupon=CODE → a single-use nonce and the exact message to sign (personal_sign), with the coupon code inside it so one signature cannot be replayed against another coupon.
  3. POST /claims { code | couponId, challengeId, signature } → the signature must recover to the user's primary EVM address. That marks the address verified and, 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.

RaceHandled by
Two claims for one couponUPDATE coupons … WHERE status = 'ISSUED' RETURNING id — the state change is the check — plus UNIQUE (coupon_id) on claims
Retried POST /claimsINSERT … 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 cooldownpg_advisory_xact_lock(hashtext(userId)) for the rest of the transaction
One issuer signing twiceUNIQUE (claim_id, issuer_address), mirroring the contract's ordering rule
Two relayer transactions with the same nonceNonceManagerService: one in-flight submission, nonce from a local counter, resync from the chain on failure
Overlapping poll windowsUNIQUE (srcChainId, txHash, outputIndex); a duplicate is a no-op update of lastSeenAt
Re-reading a block range after a restartUNIQUE (payment_ref) on settlements
Illegal state transitionscoupons_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.

GroupKeys
RuntimeNODE_ENV, PORT, APP_NAME, CORS_ORIGINS, LOG_LEVEL
Database / RedisDB_*, REDIS_*
AuthAUTH_PROVIDER, JWT_SECRET, JWT_EXPIRATION, REFRESH_TOKEN_EXPIRATION, AUTH_ISSUER, AUTH_AUDIENCE, JWKS_URI, GOOGLE_*_CLIENT_ID
IndexerINDEXER_BASE_URL, INDEXER_API_KEY, PAYMENT_POLL_*
MoneyCASHBACK_BPS (500 = 5 %), UTL_USD_RATE, PRICING_*, ACCRUAL_*
MerchantsADMIN_API_KEY, MERCHANT_ADDRESS, MERCHANT_NAME, MERCHANT_SRC_CHAIN_ID, MERCHANT_TOKEN
ChainsSUPPORTED_CHAINS, SUPPORTED_ASSETS, CONFIRMATION_DEPTHS, RPC_URLS, RPC_SHARING_ALLOWED_CHAINS, TOKEN_ADDRESSES, REWARD_CHAIN_ID
ContractsCOUPON_CLAIM_CONTRACT_ADDRESS, UTILITY_TOKEN_CONTRACT_ADDRESS, UTILITY_TOKEN_CONTRACT_ABI
TransactionsTX_OBSERVATION_TIMEOUT_MS, TX_SWEEP_INTERVAL_MS, BALANCE_CACHE_TTL_MS
ClaimsATTESTATION_THRESHOLD, CLAIM_COOLDOWN_HOURS, CLAIM_DEADLINE_SECONDS, CLAIM_SWEEP_INTERVAL_MS
IssuerISSUER_ID, ISSUER_RPC_URLS, ISSUER_SIGNING_KEY, ISSUER_PRICE_PROVIDER, PRICE_TOLERANCE_BPS, PRICE_WINDOW_SECONDS
RelayerRELAYER_RPC_URLS, RELAYER_SIGNING_KEY, RELAYER_CONFIRMATIONS, RELAYER_MAX_FEE_GWEI, RELAYER_DEADLINE_MARGIN_SECONDS
Watcher / monitorSETTLEMENT_*, MONITOR_*, ALERT_WEBHOOK_URL, ALERT_TELEGRAM_CHAT_ID
KeysSIGNER_KEY_PASSWORD, SEED_BACKUP_ENCRYPTION_KEY

Two rules the code enforces at startup rather than in review:

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:

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:

Known limitations