Operations

run, test, deploy, watch

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

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.

1 Register a merchant POST /merchants · x-admin-key nothing else earns coupons 2 Link the payer POST /wallets a declaration, not a proof 3 Pay the merchant USD₮ on mainnet then wait for depth 4 npm run poll:once ingest → price → accrue coupon appears, ISSUED 5 Claim the coupon challenge → sign → POST the one user signature 6 npm run issue:once verify on its own node ATTESTED at K signatures 7 npm run relay:once preflight + claim() CLAIM_SUBMITTED 8 Settle watcher reads Claimed CLAIMED · UTL minted

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

CommandDoes
npm run key:passwordgenerate a SIGNER_KEY_PASSWORD (32 random bytes)
echo 0x<privkey> | npm run key:encryptwrap a signing key into the enc:argon2id$… form for an env file
npm run verify:signersign the contracts fixture with the real WDK account (outside Jest, which cannot import the ESM signer)
npm run dev:tokena 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 -- --checkverify the guardian key can actually pause
npm run migration:generate -- src/database/migrations/Namegenerate a migration (must report no changes on a clean DB)
npm run test:covfull suite with coverage gates

CI

RepoPipeline
backend check — lint, formatting, tests against a Postgres service, with the contracts repo checked out for the drift fixtures; buildnest 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

Runbook

Type what you are seeing — an error string, a status, a component — to find the row.

SignalMeansAction
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.