Running the three repositories locally, the one-shot commands that walk a payment through to a mint, what CI enforces, how deployment works, and what to do when an alert fires.
Local bring-up
Backend
npm ci
cp .env.example .env # DB, indexer key, contract addresses, RPC maps
npm run docker:up # Postgres + Redis + Adminer
npm run migration:run # 13 migrations
NODE_ENV=development npm run seed # test user + the demo signer addresses
npm run dev
- API:
http://localhost:3000/api - Swagger UI:
http://localhost:3000/docs(JSON at/docs/json) - Health:
http://localhost:3000/health— outside the/apiprefix, where load balancers and container health checks look
Each background role is its own process with its own env file:
npm run dev # API + poller + pricing + accrual
ISSUER_ENV_FILE=.env.issuer-a npm run issuer # attestation loop
RELAYER_ENV_FILE=.env.relayer npm run relayer
SETTLEMENT_ENV_FILE=.env.settlement npm run settlement
MONITOR_ENV_FILE=.env.monitor npm run monitor
.env.issuer.example, .env.relayer.example,
.env.settlement.example and
.env.monitor.example document what each process needs.
Contracts
git clone --recurse-submodules <repo> # or: git submodule update --init --recursive
npm ci
cp .env.example .env # DEPLOYER_PRIVATE_KEY, ETHERSCAN_API_KEY
forge build && forge test
Mobile
npm install # postinstall generates .wdk/ and .wdk-bundle/
cp .env.example .env # GOOGLE_*_CLIENT_ID, API_BASE_URL
bundle install && (cd ios && bundle exec pod install) # iOS only, first clone
npm start # WDK bundle watcher + Metro
npm run ios # or: npm run android
API_BASE_URL must point at the backend's /api
prefix. On a device or Android emulator, localhost is the
phone — use the host's LAN address (or 10.0.2.2 on the Android
emulator).
Walkthrough: payment → mint
One-shot equivalents of the loops. Each runs a single pass and exits, which is what makes a demo followable:
npm run poll:once # ingest → price → accrue
npm run issue:once # verify + sign the pending claims
npm run relay:once # preflight + claim() + wait for the receipt
Order of operations for an end-to-end run. Step through it, or click any stage for the exact command and what to check before moving on.
Click a stage for the command and the check that tells you it worked. Steps 4, 6 and 7 are the one-shot equivalents of loops that normally run on timers — one pass, then exit, which is what makes a demo followable.
Utilities
| Command | Does |
|---|---|
npm run key:password | generate a SIGNER_KEY_PASSWORD (32 random bytes) |
echo 0x<privkey> | npm run key:encrypt | wrap a signing key into the enc:argon2id$… form for an env file |
npm run verify:signer | sign the contracts fixture with the real WDK account (outside Jest, which cannot import the ESM signer) |
npm run dev:token | a JWT the guard accepts (local only) |
npm run dev:token sign <nonce> <coupon> | produce the claim-screen signature without the app |
npm run monitor:pause-drill -- --check | verify the guardian key can actually pause |
npm run migration:generate -- src/database/migrations/Name | generate a migration (must report no changes on a clean DB) |
npm run test:cov | full suite with coverage gates |
CI
| Repo | Pipeline |
|---|---|
| backend |
check — lint, formatting, tests against a Postgres
service, with the contracts repo checked out for the drift
fixtures; build — nest build plus a
Docker image build (not pushed)
|
| contract |
forge fmt --check, the full 119-test suite, a 90 %
line-coverage floor on src/, and Slither at
fail-on: low
|
| mobile |
husky hooks locally: commit-msg validation,
lint-staged (which also rebuilds the WDK bundle when
wdk.config.js is staged),
post-merge/post-checkout bundle rebuilds
|
Backend deployment is Dokploy (self-hosted) watching
main. CI does not deploy — it gates: branch protection on
main requires check and build, so a
red pipeline cannot reach the branch Dokploy pulls from.
Deployment
Backend — one image, five services
The production image is dist/main.js on
npm ci --omit=dev: no ts-node, no
src/ on disk. Every role is the same image with a different
PROCESS_ROLE (api, issuer,
relayer, settlement, monitor); an
unrecognised value fails fast rather than silently booting the API.
.env.prod is one file for all five — paste it into each
service and change PROCESS_ROLE.
Only the API should run migrations (migrationsRun: true);
the workers attach to the schema it made. Two API replicas booting
together race on the migrations table — this is a single-instance
assumption today.
Because the image cannot run npm run seed, signer addresses
ship as reference data in the SeedSigners migration. The seed
script's test user is gated on NODE_ENV === 'development' and
never exists in production.
Pre-deployment checklist: tests pass, no secrets in code or .env, migrations generate cleanly, build succeeds, /health responds, .env.example matches all required vars.
Contracts — Sepolia
# 1. fill in script/config/ethereum-sepolia.json (every address; 0x0 is refused
# except governor, where 0x0 means "use the deployer")
# 2. fund the deployer: ~0.015 ETH at 2 gwei (~7.3M gas)
# 3. dry run — free, and runs every check against live Sepolia state
forge script script/Deploy.s.sol:Deploy --rpc-url "$SEPOLIA_RPC_URL"
# 4. broadcast + verify
forge script script/Deploy.s.sol:Deploy \
--rpc-url "$SEPOLIA_RPC_URL" --broadcast --verify -vvv
The script asserts the whole wiring after deploying and writes
deployments/<chainId>.json only on a real
broadcast — a dry run predicts addresses from the deployer nonce,
and writing those looks identical to a successful deployment.
Observability
-
Health.
GET /health— liveness plus database connectivity. -
Logs. Structured (Pino) to stdout; alerts appear as
security_event=…lines. Never log PII, tokens or seed material. -
Alerts.
AlertServicerequires asubjecton every alert — an alert that names the claim or thepaymentRefsends someone to the row instead of on a hunt. Optionally POSTed toALERT_WEBHOOK_URL; withALERT_TELEGRAM_CHAT_IDset and a TelegramsendMessageURL, the payload is rewritten into{chat_id, text}. A pager that is down never takes the process with it. -
Cross-process counters.
service_counterscarries indexer request / error / 429 counts, which the monitor reads from another process. -
Reconciliation. The monitor compares minted supply
against the payments that justify it, and the settlement watcher raises a
CRITICAL alert on a
Claimedevent with an unknownpaymentRef.
Runbook
Type what you are seeing — an error string, a status, a component — to find the row.
| Signal | Means | Action |
|---|---|---|
Claimed with an unknown paymentRef |
a mint happened outside the pipeline — the relayer key or the issuer set is compromised | Pause immediately with the guardian key, then reconcile. Do not unpause until the extra mint is explained. |
| Minted supply > sum of justified coupons | same class of problem, found by reconciliation rather than by an event | Pause, then audit settlements against claims and payments. |
ATTESTATION_REJECTED |
an issuer disagreed with the payment or the price after checking its own node | Read the reason on the claim row. Do not retry — a refusal is a statement about the payment. The coupon is already released back to ISSUED. |
SUBMISSION_FAILED |
the relayer's preflight refused before spending gas (paused, cap, deadline, role, ordering) | Compare backend config against the deployment record; caps and K are the usual culprits. |
| A process refuses to start: shared RPC endpoint | two verifiers behind one provider are one verifier wearing two hats | Give it its own endpoint. Waive per chain via RPC_SHARING_ALLOWED_CHAINS only where a single free public API is the only option (Tron, Bitcoin). |
A process refuses to start: NO_NODE |
no RPC endpoint configured for the payment's srcChainId |
Add the chain to that process's *_RPC_URLS map. Never point it at the reward chain's node. |
| A process refuses to start: signer not registered | the process's own address is not an active signers row |
Add it via a migration (production) — and check it also holds the matching on-chain role. |
Claims stuck in CLAIM_SUBMITTED past the deadline |
submitted but never settled | The claim sweep releases the coupon back to ISSUED. Investigate the relayer's nonce queue and the receipt wait. |
Rising indexer 429s in service_counters |
polling budget exhausted | Widen PAYMENT_POLL_* intervals. The indexer is the only shared third-party budget; issuers must never call it. |
Nothing retries automatically on the money path. Both the
issuer and the relayer refuse rather than retry, and every failure path
releases the coupon back to ISSUED — so “stuck” is a state
you investigate, never one you clear by re-running the loop until it says
yes.