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 |