Pay in USD₮, get 5 % back as an on-chain token.
A non-custodial multi-chain wallet plus a cashback pipeline: a customer pays a merchant in USD₮, the backend observes the payment, and 5 % of it comes back as a coupon the customer redeems for an on-chain utility token (UTL).
Backend
NestJS + TypeORM + PostgreSQL. One codebase, five processes: API, issuer, relayer, settlement watcher, monitor.
Smart contracts
Solidity / Foundry. UTL (LayerZero OFT) and
CouponClaim (K-of-N issuer threshold claims) on
Ethereum Sepolia.
Mobile
React Native 0.86 + WDK worklet, MobX. Google sign-in, biometrics, wallet create/restore, encrypted backup, QR scan-to-pay, real transfers, coupons and the ERC-1271 claim signature.
Integration
The contracts between the three repos: paymentRef, the
EIP-712 Entitlement, shared fixtures, the REST surface.
Team
Who built what, how the work was split and reviewed, the AI tooling used, and what would come next.
Lessons learned
How we scoped what to build ourselves, and the conventions our app settled on while working against the WDK.
Known gaps
The defects we found in our own code and the hardening that did not fit the schedule — with severity, cause and the order we would fix them in.
Repositories
| Directory | Stack | What it owns |
|---|---|---|
backend/ |
NestJS, TypeScript (strict), TypeORM, PostgreSQL, Redis | Payment detection, pricing, coupon accrual, claim state machine, attestation, relaying, reconciliation, REST API |
contract/ |
Solidity ^0.8.24, Foundry, OpenZeppelin, LayerZero OFT |
UTL reward token, CouponClaim threshold
claim contract, deploy script, invariant suite
|
mobile/ |
React Native 0.86.2, React 19, MobX, WDK React Native Core | Non-custodial wallet UX: key generation inside the WDK worklet, biometric gate, session, encrypted backup, cashback screens |
The whole path, end to end
One payment, from the till to a minted token. The dots are work in flight; step through it, or click any box.
Click a box for what it holds and what it may write. Dashed lines are reads or out-of-band actions. Payments are watched on mainnet; rewards settle on Sepolia.
The same path as plain text
device ──pays USD₮──▶ merchant address (mainnet: Ethereum / Arbitrum One)
│
WDK Indexer API ◀─────────┘
│ polled, never trusted
▼
┌──────────────────────────────────────────────────────────┐
│ API process │
│ payment poller → pricing → accrual → coupon (5 %) │
│ REST for the app: auth, wallets, secrets, coupons, │
│ claims, transactions, balances │
└──────────────────────────────────────────────────────────┘
│ claim, signed once by the user's wallet (ERC-1271 / EOA)
▼
┌──────────────┐ K signatures ┌──────────────┐ claim() ┌─────────────┐
│ issuer × N │ ───────────────▶ │ relayer │ ────────▶ │ CouponClaim │
│ own RPC+key │ EIP-712 │ own RPC+key │ pays gas │ (Sepolia) │
└──────────────┘ └──────────────┘ └─────────────┘
▲ │
│ Claimed event ▼
┌──────────────┐ ┌────────────────────┐ ┌──────────┐
│ monitor │◀── reconciles ───│ settlement watcher │ │ UTL mint │
│ guardian key │ can pause() └────────────────────┘ └──────────┘
└──────────────┘
The same picture is available as an interactive diagram — click a process for what it holds and what it may write, or step through one payment from the till to a minted token. How the app's requests reach these endpoints is on the mobile page.
Two chains, on purpose. Payments are detected on
mainnet (Ethereum, Arbitrum One) through the hosted WDK Indexer
API. Rewards settle on Ethereum Sepolia, where
CouponClaim mints UTL. Nearly every mistake on this path
comes from confusing the two, so the split is spelled out everywhere it
matters — including in the RPC configuration, which is a map keyed by
srcChainId.
Trust model in one paragraph
Payment detection depends on a third party (the indexer), so
no single process can turn an indexer answer into a mint.
The issuers re-verify every payment against their own nodes and sign an
EIP-712 Entitlement; the relayer re-verifies again before
spending gas and is the only key allowed to write to the chain; the
contract requires K distinct issuer signatures and enforces per-claim and
per-epoch mint caps; the monitor reconciles minted supply against the
payments that justify it and holds the pause key. Each process runs with
its own key and its own RPC endpoint, and refuses to start if it shares an
endpoint with another.
| Process | Holds | May write | Never |
|---|---|---|---|
| API | no chain key | users, wallets, coupons, claims | attest, submit |
| Issuer (×N) | one ISSUER_ROLE key |
attestations, claim rejection |
spend gas, call the indexer |
| Relayer | the only chain-writing key | claim status | create attestations |
| Settlement watcher | no key | settlements, claim status | anything on-chain |
| Monitor | guardian (PAUSER_ROLE) |
nothing | repair, attest, submit |
Status at a glance
| Area | State | Notes |
|---|---|---|
| Backend money path | shipped | 612 tests / 57 suites, 94 % statements, 95 % lines |
| Contracts | deployed |
Sepolia, 136 tests across 6 suites, 100 % line/branch on
src/, Slither clean
|
| Transaction history, both directions | shipped |
The device can only report what it broadcasts, so a wallet
transfer poller ingests incoming transfers from the indexer and
confirms outgoing ones, keyed
UNIQUE (userId, srcChainId, txHash, outputIndex)
|
| Mobile: auth, biometrics, wallet lifecycle, backup | wired to backend |
/auth/google, /auth/refresh,
/secrets/*. Two real backups — key in the device
keychain or in Google Drive's appDataFolder, never
next to the ciphertext
|
| Mobile: balances, transfers, QR pay, coupons, claims | end to end |
Balances and transfers from the WDK worklet;
/wallets, /transactions,
/coupons, /claims/*,
/pricing/live consumed. 357 tests across 41 Jest
suites, 90 % coverage gate on the logic layers
|