Lessons learned

Strategic Takeaways & Architecture Decisions

Notes from our own build, written down while they are still fresh: how we scoped what to write ourselves, and the conventions we ended up with while working against the WDK. These are our takeaways from two weeks inside one codebase, not advice — and, at the end, where they point if the project continued.

1. We wrote our own backend around a ready-made indexer

The reference material depends on packages in repositories we did not have access to inside the two-week window, so early on we had to choose between waiting on access requests and starting to build. We chose to build, and that made the reuse boundary an explicit decision rather than something we discovered late.

We drew the line at the indexer: kept it as the transfer source and wrote the rest of the money path ourselves, following the patterns visible in the reference material rather than importing it. In the code that is backend/src/indexer/ — a narrow IndexerService behind ITransfer/ITransferQuery — with everything downstream (PaymentPollerService, the confirmation policy, accrual, attestation, relay) written against those two interfaces and nothing else.

The accident turned out to be a good boundary. Because the poller only knows ITransfer and an IndexerCursor (block, transaction index, transfer index), swapping the indexer for another source — or adding a second one to cross-check against — is one adapter, not a rewrite. The deterministic ordering and the per-merchant cursor are ours, so replay and gap handling never depended on indexer behaviour we could not read.

2. The conventions we settled on around the WDK

Our app-side conventions, listed because they are the part of the codebase a reader is most likely to want context on. They are shaped by our own product — one wallet, biometric signing, a cashback flow — and are not claims about how the SDK should be used in general.

Convention Where it lives
We model the worklet as a state machine on our side WdkAppState (INITIALIZING → NO_WALLET | LOCKED | READY | ERROR) is mirrored into a single WdkAppStore, so every screen reads readiness from one place instead of guessing
Two kinds of readiness gate, not one useEnsureWdkReady() for user-driven writes (it alerts and offers a retry) and useIsWdkReady() for read-only paths like fee estimation (never alerts). Splitting the two stopped background refreshes from throwing dialogs at the user
We read status from refs rather than the closure The guard survives an await — a biometric prompt can take seconds, and the state captured before it is stale by the time signing starts
SDK hooks are wrapped, screens do not call them directly useWallet, useAssetBalances, useAssetTransfer, useReceiveAddress — a thin layer in shared/lib/hooks/wallet/ is what made the beta version bumps (1.0.0-beta.x) a one-file change each time
Networks are configuration, assets are a registry wdk.config.js maps logical networks to wallet packages and the bundler regenerates the worklet on change; shared/config/assets.ts is the single table everything (balances, pricing, fees, decimals) reads. Adding an asset is a row
Backend uses the WDK too — for signing only createWdkSigner keeps @tetherto/wdk-wallet-evm behind an IWdkSigner interface, and verifies that the key the WDK derives matches the address we expected before it signs anything

One integration detail from our setup: the WDK EVM package is ESM and our backend compiles to CommonJS, so a plain import() gets rewritten into require() by TypeScript and fails at runtime. We used a new Function('specifier', 'return import(specifier)') indirection in wdk-signer.ts and documented it in place — a workaround on our side, chosen because moving the whole service to ESM was not worth it inside the window.

Next steps if the project continued

Each lesson above has a row here: the indexer boundary argues for a second transfer source to cross-check it, the crypto work argues for a key-rotation command and KMS-backed keys, and the threshold design argues for raising K and N above 1 and moving attestation to a Merkle root per epoch. Owner is the team named on the Team page. This table is forward work — the defects and hardening we know are outstanding are listed separately under Known gaps.

Step Why it is next Owner
Raise K and N above 1 The threshold design is built and tested — sorted signers, 1 ≤ K ≤ N held through every role change — but the demo runs a single issuer, so today's guarantee is really 1-of-1. More issuers on independent nodes is a configuration change plus a key ceremony, not a rewrite Backend
Merkle-tree verification of coupons Today every claim carries K ECDSA signatures and CouponClaim recovers each one on-chain (~3k gas apiece). Issuers signing one Merkle root per epoch instead turns a claim into a root lookup plus a proof: attestation cost stops scaling with K, issuers sign once per epoch rather than once per user, and the root is a single public commitment anyone can audit a coupon against — including the coupons that were not in it Backend + Contracts
Cross-chain UTL pools over LayerZero UTL is already a LayerZero OFT, so the token can move between networks without a wrapper — but only one endpoint is wired today, so a reward minted on Sepolia stays there. Wiring peers and per-chain rate limits, plus liquidity pools on each side, lets a user earn on the chain they paid on and spend on the chain they hold gas on, which is what makes the reward usable rather than a balance to look at Contracts + Backend
Batch settlement — one transaction, many claims The relayer submits one claim() per coupon and pays base gas every time. With Merkle roots in place a batch entry point amortises that over an epoch, which is the difference between cashback that costs more to pay out than it is worth and cashback that does not Backend + Contracts
End-to-end tracing across the five processes Work moves as database rows, so a single payment's journey is reconstructed today by joining tables by hand. One trace id carried on the paymentRef through poll, accrue, attest, submit and settle turns “where did this coupon stall” into one query, and it is the prerequisite for any latency target Backend
Merchant-facing surface Merchants are rows an operator inserts. Self-service onboarding, address verification and a settlement report are what turn the pipeline into something a merchant can be sold, and none of it touches the money path — it reads the same tables the app does Backend
Push notifications on the coupon lifecycle Confirmation, accrual and settlement all take minutes, and the app only learns about them if it is open and polling. The states already exist and are already database-enforced, so this is a listener on the same transitions the watcher publishes Mobile + Backend
iCloud as a second cloud key store Backups split the ciphertext (backend) from the key (device keychain or Google Drive appDataFolder), and Drive is the wrong default for an iPhone user who never signs into Google. The CloudKeyProvider interface, the provider-neutral outcome codes and the versioned key envelope were all written for a second implementation, so CloudKit's private database is one adapter plus the platform split — not a change to the backup model Mobile
Multiple accounts in one app Everything under useWallet() targets DEFAULT_WALLET_ID, and the backend holds one secret blob per kind per user — one wallet's worth — which is why creating a second wallet today means one that can never be backed up. It needs a wallet id threaded through secure storage, a keyed backup slot on the backend and both key stores, an account switcher, and address linking that is per wallet rather than per user Mobile + Backend
More tokens and networks in the wallet assets.ts currently registers BTC, ETH/USDT on Sepolia and Arbitrum, POL/USDT on Polygon, TRX/USDT on Tron and UTL. The registry and wdk.config.js were built to make each addition a row plus a package, so the work is coverage and testing — price tickers, decimals, fee tokens per chain — not architecture Mobile
Haia as an in-app AI agent (haia.finance) A dedicated chat screen where the user asks for a balance, a transfer or a swap in plain language instead of walking the tab bar. The agent is a caller, not a signer: every action it proposes goes through the same useWallet() path and the same confirmation sheet the manual flow uses, so the trust boundary does not move — the model can draft a transaction, only the device key can sign it. Scope is one screen plus a tool surface over the existing wallet actions, and the rules worth writing first are the refusals: no seed phrase in a prompt, no auto-send, hard caps per session Mobile + Backend
In-wallet swaps and bridging (Relay and other DeFi routes) Today a user holding USDT on Polygon and needing gas on Arbitrum leaves the app. A swap/bridge aggregator such as Relay quotes a route and returns a transaction the wallet signs, so the piece we build is quote → confirm → track, not liquidity. It leans on work already done: the asset registry in assets.ts gives the token list, the WDK gives the signing, and the cross-chain UTL row above wants the same bridging surface — the risk to design for is quote staleness and partial fills, which the coupon state machine has no equivalent of Mobile
Fiat on-ramp and off-ramp The wallet can hold and move value but cannot be funded from inside it — the first balance has to arrive from somewhere else, which is the step most new users never finish. A hosted provider (MoonPay, Transak or similar) keeps card data, KYC and the money-transmitter licence outside our perimeter: we hand it a deposit address the WDK already derives and a webhook receives the settlement, so the sensitive half of the flow is one we never store. Off-ramp is the mirror and the harder one — it needs a payout to a verified bank account and an unambiguous refund path when the provider rejects after we have already sent the funds, so it should ship second, behind on-ramp Mobile + Backend
A contact list for send addresses Sending means pasting an address every time, which is the cheapest place in the app to lose money to a typo or a clipboard swap. Named contacts, per network, stored on-device next to the other secure-storage entries — no backend, no new key material — turn the send screen's riskiest field into a pick list, and address linking already gives the shape of an address-per-chain record Mobile
A second transfer source to cross-check the indexer Payment detection has one upstream today. Everything downstream already speaks ITransfer and a cursor, so a second adapter reconciling against the first would turn a silent indexer gap — the failure mode that quietly under-pays users — into a monitor alert Backend
Key rotation and re-encryption for stored secrets The blob format carries its own KDF parameters precisely so they can be raised later, but nothing walks the table and re-encrypts yet. A rotation command is small while the data set is small, and it is the only way the KDF_FLOOR check ever gets to be more than a promise Backend
Non-EVM payment verification (BTC, Tron, Spark) Payments on those chains are ingested but cannot be verified — no issuer runs a node of that kind, so they are refused rather than guessed at Backend
KMS-backed signing keys kms:<arn> is the production key shape and is deliberately unimplemented rather than faked; a real deployment needs it before it holds real value Backend