Integration

what the three repos agree on

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.

mobile/ React Native + WDK holds the user's keys REST + JWT /api · error envelope backend/ NestJS · 5 processes issuer & relayer keys contract/ CouponClaim · UTL Ethereum Sepolia paymentRef the nullifier bytes Entitlement EIP-712 digest roles ↔ signers addresses, K and N deployments/ 11155111.json

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.

BoundaryShared artefactEnforced by
backend ↔ contractspaymentRef bytestest/fixtures/payment-refs.json, read by both suites
backend ↔ contractsEIP-712 Entitlement digesttest/fixtures/entitlement.json, read by both suites
backend ↔ contractsissuer / relayer / guardian addressessigners table vs. on-chain roles; startup self-check
backend ↔ contractsaddresses, domain separator, capsdeployments/11155111.json, committed
mobile ↔ backendREST + error envelopeOpenAPI at /docs/json; DTO validation
mobile ↔ contractsthe recipient addressclaim-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

ChainsrcChainIdKindIndexer name / tokens
Ethereum1EVMethereum · usdt
Ethereum Sepolia11155111EVMsepolia · usdt
Arbitrum One42161EVMarbitrum · usdt, xaut
Polygon137EVMpolygon · usdt, xaut
Arbitrum Sepolia421614EVMarbitrum-sepolia · usdt
Polygon Amoy80002EVMpolygon-amoy · usdt
Tron4294967297 (232+1)TRONtron · usdt
Bitcoin4294967298 (232+2)BITCOINbitcoin · btc
Spark4294967299 (232+3)SPARKspark

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

domainname "CouponClaim", version "1", chainId + verifyingContract from the deployment
typeEntitlement(address recipient,uint256 amount,bytes32 paymentRef,uint256 deadline)
typehash0xdd065a0d40a532db6696301233dd7aa4b8c1d2add45574549d672c819b59c749
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 roleBackend sideChecked
ISSUER_ROLEissuer process key (ISSUER_SIGNING_KEY)process refuses to start unless its own address is an active signers row
RELAYER_ROLErelayer process keysame; the relayer also checks an attestation's author against signers before spending gas
PAUSER_ROLEmonitor guardian keynpm 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 fieldBackend env
couponClaimCOUPON_CLAIM_CONTRACT_ADDRESS
utlUTILITY_TOKEN_CONTRACT_ADDRESS, UTILITY_TOKEN_CONTRACT_ABI
chainIdREWARD_CHAIN_ID (and the Sepolia entry of every *_RPC_URLS map)
thresholdATTESTATION_THRESHOLD
issuers, relayer, guardianthe signers table
perClaimCap, epochCap, epochLengthmonitor 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 needEndpointWired
Sign inPOST /auth/google { idToken, type }yes
Keep the sessionPOST /auth/refreshyes
Back up the seedPOST /secrets/entropy, POST /secrets/seedyes
Detect an existing wallet / verify a phraseGET /secrets/entropyyes
Earn cashback at allPOST /wallets (link every derived address)no
Balances, historyGET /balances, GET /transactionsno
CouponsGET /coupons, GET /coupons/by-code/:codeno
ClaimGET /claims/challengePOST /claimsGET /claims/:idno
Rates & depthsGET /configno

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

GET /claims/challenge?coupon=CODE nonce + the exact message POST /claims { signature, Idempotency-Key } claim row · coupon PENDING_ATTESTATION K signatures → ATTESTED claim(recipient, amount, paymentRef, deadline, sigs) Claimed(paymentRef, recipient, amount) settlement row · claim CLAIMED GET /claims/:id → CLAIMED Mobile app signs once API no chain key Issuer × N own node, own key Relayer pays the gas CouponClaim Sepolia Watcher no key at all

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

CheckWhereWhat it catches
payment-refs.jsonPaymentRef.t.sol + payment-ref.spec.tstwo nullifiers for one payment (encoding, chain ids, Bitcoin byte order)
entitlement.jsonEntitlement.t.sol + entitlement.spec.tssignatures that can never reach K because the digests differ
golden-amounts.jsonaccrual + issuer specsa rounding difference between accrual and re-computation — a different amount is a different digest
isolation guardsissuer-independence.spec.ts, relayer-isolation.spec.tsthe indexer client, a controller, or an attestation write appearing in a process that must not have it
migration driftmigration:generate on a clean DB must report no changesentities and SQL disagreeing
deploy assertionsDeploy.s.sol re-reads the chaina half-wired deployment (no minter, threshold below K, peers configured)
npm run verify:signerplain node, outside Jestthe 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.

MismatchSymptomWhere it surfaces
EIP-712 domain differs from the deploymentevery claim reverts NotIssuer / never reaches Kentitlement.spec.ts; relayer preflight
paymentRef derivation differsdouble mint for one payment, or a nullifier that never matchespayment-ref.spec.ts; monitor reconciliation
Backend threshold < contract Ksubmission reverts NotEnoughSignatures()relayer preflight, before gas
Amount above perClaimCapExceedsPerClaimCap()relayer preflight
Epoch budget exhaustedExceedsEpochCap()relayer preflight; monitor alert
Attestations unsorted or duplicatedUnsortedOrDuplicate()DB unique index + relayer preflight
Deadline passed while queuedExpired(); coupon released back to ISSUEDclaim sweep
Claimed for an unknown paymentRefa mint outside the pipelinesettlement watcher → CRITICAL alert; guardian may pause()
Two processes sharing an RPC endpointprocess refuses to startstartup check (waivable per chain via RPC_SHARING_ALLOWED_CHAINS)
Payment on a non-EVM chainclaim refused — no issuer has a node of that kindissuer verification