Everything that has to agree across the three repositories: the
paymentRef bytes, the EIP-712 digest, the on-chain roles
mirrored in the database, the deployment record the backend reads, and the
REST surface the app consumes. Each of these is a place where two
implementations can drift silently, so each has a mechanism that turns
drift into a failing test rather than a stuck payout.
Boundary map
Five artefacts sit between the three repositories. Click one for what has to agree and what happens when it does not; step through them to see which pair of repos each one binds together.
Click a repository or one of the four shared artefacts. Dashed lines are configuration that has to be kept in step by hand; solid lines are derived bytes that a committed fixture pins on both sides.
| Boundary | Shared artefact | Enforced by |
|---|---|---|
| backend ↔ contracts | paymentRef bytes | test/fixtures/payment-refs.json, read by both suites |
| backend ↔ contracts | EIP-712 Entitlement digest | test/fixtures/entitlement.json, read by both suites |
| backend ↔ contracts | issuer / relayer / guardian addresses | signers table vs. on-chain roles; startup self-check |
| backend ↔ contracts | addresses, domain separator, caps | deployments/11155111.json, committed |
| mobile ↔ backend | REST + error envelope | OpenAPI at /docs/json; DTO validation |
| mobile ↔ contracts | the recipient address | claim-time personal_sign, recovered server-side |
Contract 1 — paymentRef
paymentRef = keccak256(abi.encode(srcChainId, txHash, outputIndex))
ABI types are exactly (uint256, bytes32, uint256)
The same value on both sides: the contract's replay nullifier and the
backend's dedup key are literally the same bytes. TypeScript derives it in
src/chains/ (viem encodeAbiParameters +
keccak256); Solidity has a helper in
test/PaymentRef.t.sol that exists only for the cross-check —
CouponClaim itself never derives it, it receives
bytes32 and nullifies it.
Chain id registry
| Chain | srcChainId | Kind | Indexer name / tokens |
|---|---|---|---|
| Ethereum | 1 | EVM | ethereum · usdt |
| Ethereum Sepolia | 11155111 | EVM | sepolia · usdt |
| Arbitrum One | 42161 | EVM | arbitrum · usdt, xaut |
| Polygon | 137 | EVM | polygon · usdt, xaut |
| Arbitrum Sepolia | 421614 | EVM | arbitrum-sepolia · usdt |
| Polygon Amoy | 80002 | EVM | polygon-amoy · usdt |
| Tron | 4294967297 (232+1) | TRON | tron · usdt |
| Bitcoin | 4294967298 (232+2) | BITCOIN | bitcoin · btc |
| Spark | 4294967299 (232+3) | SPARK | spark |
Non-EVM chains have no EIP-155 id, hence the reserved ids above
2**32. outputIndex is the log index on EVM and
the output index (vout) on Bitcoin and Spark. On Bitcoin and
Spark, txHash is the txid as displayed — not
byte-reversed into internal order. That reversal is the likeliest source of
drift, so it has its own committed vector.
The indexer's blockchain name and our srcChainId
are different namespaces. The hosted indexer serves mainnet under short
names — arbitrum is Arbitrum One, not its testnet — so
testnets carry explicit names and one word cannot mean two chains.
Contract 2 — the EIP-712 Entitlement
| domain | name "CouponClaim", version "1", chainId + verifyingContract from the deployment |
|---|---|
| type | Entitlement(address recipient,uint256 amount,bytes32 paymentRef,uint256 deadline) |
| typehash | 0xdd065a0d40a532db6696301233dd7aa4b8c1d2add45574549d672c819b59c749 |
| domain separator (Sepolia) | 0xb99e02fda448be8955396e89d99bf0e97b55d84d83efb75ddeef356bc6006237 |
Solidity builds it with _hashTypedDataV4 over
abi.encode(ENTITLEMENT_TYPEHASH, recipient, amount, paymentRef,
deadline); TypeScript builds it with viem's
hashTypedData in
src/issuer/entitlement.ts. Both must produce identical bytes,
and entitlement.json pins one domain, one message and the
resulting digest for both.
The message deliberately contains no couponId
(a backend-chosen number; two coupon rows for one payment would give the
contract two ids it cannot tell apart), no nonce and
no user signature — replay protection is the
paymentRef nullifier, and the user's signature lives one layer
up, at the API.
chainId and verifyingContract must be read from
the deployed contract (eip712Domain(), ERC-5267) and
cross-checked against DOMAIN_SEPARATOR() — never copied by
hand into backend config. Hand-copied domains are exactly how you get K
signatures over K different digests, none of which reach the threshold.
Signature ordering
The contract requires issuerSigs sorted by recovered signer
address, strictly ascending. The backend mirrors this in the schema with
UNIQUE (claim_id, issuer_address) on attestations
and sorts before submitting; the relayer preflights the whole rule
off-chain before spending gas.
Contract 3 — roles and the signers table
| On-chain role | Backend side | Checked |
|---|---|---|
ISSUER_ROLE | issuer process key (ISSUER_SIGNING_KEY) | process refuses to start unless its own address is an active signers row |
RELAYER_ROLE | relayer process key | same; the relayer also checks an attestation's author against signers before spending gas |
PAUSER_ROLE | monitor guardian key | npm run monitor:pause-drill -- --check |
MINTER_ROLE (on UTL) | — | held by CouponClaim alone; asserted by the deploy script and reconciled by the monitor |
The signers table holds addresses only, never
keys. Because the production image has no src/ and no
ts-node, signers are reference data shipped in the
SeedSigners migration rather than a seed script.
K and N are configuration, not code. The contract stores
threshold (K) and derives N from
getRoleMemberCount(ISSUER_ROLE); the backend has
ATTESTATION_THRESHOLD. They must match — a backend threshold
below the contract's produces claims that are submitted and revert with
NotEnoughSignatures(); above it, gas is wasted collecting
signatures the contract does not need.
Contract 4 — the deployment record
contract/deployments/11155111.json is committed on purpose. It
carries the addresses, the caps, K, the issuer set, the
domainSeparator and the entitlementTypehash, so
the backend can cross-check the EIP-712 domain instead of hardcoding a copy,
and GET /config can serve the app one consistent view.
| Deployment field | Backend env |
|---|---|
couponClaim | COUPON_CLAIM_CONTRACT_ADDRESS |
utl | UTILITY_TOKEN_CONTRACT_ADDRESS, UTILITY_TOKEN_CONTRACT_ABI |
chainId | REWARD_CHAIN_ID (and the Sepolia entry of every *_RPC_URLS map) |
threshold | ATTESTATION_THRESHOLD |
issuers, relayer, guardian | the signers table |
perClaimCap, epochCap, epochLength | monitor thresholds; also bound what a claim may legally be |
Reward chain ≠ payment chain. REWARD_CHAIN_ID
is Sepolia (11155111) — where UTL is minted. Payments are observed on
mainnet chains. Every *_RPC_URLS map is keyed by
srcChainId precisely so a verifier reaches for the node of
the chain the payment is on, and a chain with no endpoint is
refused (NO_NODE) rather than asked of the wrong node.
Contract 5 — REST between app and backend
Base URL API_BASE_URL (…/api), bearer JWT from
/auth/google, refreshed via /auth/refresh. Errors
arrive in the GlobalExceptionFilter envelope
(statusCode, timestamp, path,
message, error code), which the app unwraps into
ApiError.
| App need | Endpoint | Wired |
|---|---|---|
| Sign in | POST /auth/google { idToken, type } | yes |
| Keep the session | POST /auth/refresh | yes |
| Back up the seed | POST /secrets/entropy, POST /secrets/seed | yes |
| Detect an existing wallet / verify a phrase | GET /secrets/entropy | yes |
| Earn cashback at all | POST /wallets (link every derived address) | no |
| Balances, history | GET /balances, GET /transactions | no |
| Coupons | GET /coupons, GET /coupons/by-code/:code | no |
| Claim | GET /claims/challenge → POST /claims → GET /claims/:id | no |
| Rates & depths | GET /config | no |
Two request conventions the app must honour when those are wired:
Idempotency-Key is required on
POST /claims and POST /transactions (a retry with
the same key returns the winner's stored response rather than doing the work
twice), and the claim signature must be produced over the
message the challenge returns verbatim — it embeds the
coupon code so one signature cannot be replayed against another coupon.
End-to-end sequence
The claim half of the path, where all three repositories meet. Dashed messages are handed over as database rows and picked up by polling — there is no broker, so “sends to” means “writes a row the other one is watching for”.
The user signs exactly once, at step 1–3. Everything after that is the backend proving to itself, twice, that the payment was real.
The user signs exactly once, at the challenge step. Linking addresses is a declaration; the claim-time signature is the proof, and it takes the address from anyone who declared it first without being able to sign.
Drift checks
| Check | Where | What it catches |
|---|---|---|
payment-refs.json | PaymentRef.t.sol + payment-ref.spec.ts | two nullifiers for one payment (encoding, chain ids, Bitcoin byte order) |
entitlement.json | Entitlement.t.sol + entitlement.spec.ts | signatures that can never reach K because the digests differ |
golden-amounts.json | accrual + issuer specs | a rounding difference between accrual and re-computation — a different amount is a different digest |
| isolation guards | issuer-independence.spec.ts, relayer-isolation.spec.ts | the indexer client, a controller, or an attestation write appearing in a process that must not have it |
| migration drift | migration:generate on a clean DB must report no changes | entities and SQL disagreeing |
| deploy assertions | Deploy.s.sol re-reads the chain | a half-wired deployment (no minter, threshold below K, peers configured) |
npm run verify:signer | plain node, outside Jest | the real WDK ESM signing path, which Jest's CommonJS runtime cannot import |
Neither side may edit a shared fixture to make its own test pass. A mismatch is a real drift bug; it is cheaper as a red suite than as a claim that silently never reaches K signatures.
Failure matrix
What each mismatch actually looks like when it happens. Filter by an error
name you are staring at — NotIssuer, Expired,
NO_NODE — or by a component.
| Mismatch | Symptom | Where it surfaces |
|---|---|---|
| EIP-712 domain differs from the deployment | every claim reverts NotIssuer / never reaches K | entitlement.spec.ts; relayer preflight |
paymentRef derivation differs | double mint for one payment, or a nullifier that never matches | payment-ref.spec.ts; monitor reconciliation |
| Backend threshold < contract K | submission reverts NotEnoughSignatures() | relayer preflight, before gas |
Amount above perClaimCap | ExceedsPerClaimCap() | relayer preflight |
| Epoch budget exhausted | ExceedsEpochCap() | relayer preflight; monitor alert |
| Attestations unsorted or duplicated | UnsortedOrDuplicate() | DB unique index + relayer preflight |
| Deadline passed while queued | Expired(); coupon released back to ISSUED | claim sweep |
Claimed for an unknown paymentRef | a mint outside the pipeline | settlement watcher → CRITICAL alert; guardian may pause() |
| Two processes sharing an RPC endpoint | process refuses to start | startup check (waivable per chain via RPC_SHARING_ALLOWED_CHAINS) |
| Payment on a non-EVM chain | claim refused — no issuer has a node of that kind | issuer verification |