A non-custodial multi-chain wallet built on the Tether Wallet Development Kit. React Native 0.86.2 (React 19.2.3, TypeScript ~6.0.3), New Architecture enabled, MobX for state, React Navigation native-stack. Keys are generated and held inside the WDK Bare worklet on the device; the backend never sees a mnemonic, a plaintext seed or the key that decrypts the backup. This page describes what exists today, and says plainly where the app stops.
Current integration status. The whole path is live:
sign-in, biometrics, wallet create / restore / unlock, encrypted backup
to /secrets/* with the key held on the device keychain or
in the user's Google Drive appDataFolder, address linking through
POST /wallets, real balances and transfers through the WDK
worklet, QR scan-to-pay, broadcast reporting to
POST /transactions, the coupon list from
GET /coupons, and the claim itself — challenge, ERC-1271
signature, POST /claims. There are no fixtures left in
WalletStore: what it holds is the asset registry, server
data behind TypedRequest, and the local broadcast queue.
What is not wired is listed under
Gaps — mostly read-only conveniences
(GET /config, /users/me,
/claims/preview) the app currently does without.
Layering
src/
├── app/ composition root (registered from index.js): providers, RootStore,
│ navigation setup, global app-state sync
├── screens/ one folder per screen (kebab-case)
└── shared/ reusable, business-agnostic: ui, lib, api, config, store, types
Import rule: a slice may only import from its own layer or layers below it
(app → screens → shared), never sideways or upward. Each slice
exposes its public API through an index.ts barrel — import
from the slice root, not its internals. Path aliases
(babel-plugin-module-resolver + tsconfig paths):
@app, @screens, @features,
@shared, plus @wdk-internal.
@wdk-internal is not an npm package. It
aliases unpublished source inside
@tetherto/wdk-react-native-core
(./node_modules/@tetherto/wdk-react-native-core/src) and is
used for exactly one thing today: the wallet session lock. Those modules
are internal to WDK, not part of the supported API, and may change
between versions — the alias should go away once WDK ships a public
session-lock API.
WDK worklet bundle
The wallet engine runs in a Bare worklet. The bundle is generated locally and is not committed:
.wdk/— TypeScript declarations and the re-export used byWdkAppProvider.wdk-bundle/— compiled worklet JavaScript loaded at runtime
Regeneration is automatic in every path that could invalidate it:
| Trigger | Mechanism |
|---|---|
npm install | postinstall → npm run wdk:bundle |
| while editing | npm start runs wdk:watch (onchange) alongside Metro |
| on commit | lint-staged rule when wdk.config.js is staged |
| after pull / branch switch | husky post-merge and post-checkout |
| manual | npm run wdk:bundle |
If either folder is missing, Metro fails at bundle time. In CI, where the
bundle is not needed, use npm ci --ignore-scripts. Native
prerequisites: Android minSdkVersion 29; after adding or
updating WDK packages, run bundle exec pod install in
ios/.
Networks
Wallet modules are mapped in wdk.config.js; runtime network
config lives in src/shared/config/wdk.ts.
| Network | Package | Runtime config |
|---|---|---|
bitcoin | @tetherto/wdk-wallet-btc | a list of clients behind WDK's failover provider: Blockbook HTTPS (btc1.trezor.io/api) first, then Electrum over TLS (:50002, pingPeriod 30s), retries: 2 |
spark | @tetherto/wdk-wallet-spark | network: MAINNET |
ethereum | @tetherto/wdk-wallet-evm-erc-4337 | chainId 11155111 (Sepolia), publicnode RPC, Candide bundler + paymaster, USD₮ paymaster token — the reward chain |
arbitrum | @tetherto/wdk-wallet-evm-erc-4337 | chainId 42161 (mainnet) — the payment chain the demo merchant settles on |
polygon | @tetherto/wdk-wallet-evm-erc-4337 | chainId 137, safeModulesVersion 0.3.0 |
tron | @tetherto/wdk-wallet-tron | TronGrid, optional TRON_API_KEY/TRON_API_SECRET |
The EVM networks are ERC-4337 smart accounts: a shared
entrypointAddress, a paymaster and a
transferMaxFee of 5 USD₮, so transfers can be paid in USD₮
rather than native gas. safeModulesVersion is not optional —
the account constructor looks the Safe module addresses up by it and
throws on an unknown one.
Two details in wdk.ts that are scar tissue rather than
configuration. Bitcoin gets a client list, Blockbook first:
Electrum holds one long-lived TCP socket and a phone loses it constantly
(backgrounding, wifi↔LTE handover, an idle server hanging up), and the
client only finds out on the next call — “Connection to server lost”.
NATIVE_MAX_TRANSFER_FEE is separate from
transferMaxFee: the latter is denominated in the
paymaster token (USD₮, 6 decimals) and native fees are in wei, so reusing
it in native mode caps every transfer at a few thousand wei and rejects
all of them as “Exceeded maximum fee cost”.
Asset registry
src/shared/config/assets.ts is the single source of truth for
token metadata: eleven (asset, network) entries, each
network's gas coin listed first. Screens never hardcode a token — the home
list, the receive picker, the transfer hook and the backend DTOs all read
this array, so adding an asset is one entry.
| Helper | Answers |
|---|---|
getSrcChainId(network) | the backend's single id space for chains: real chain ids for EVM, the synthetic 4294967297+ ids the backend assigns Tron / Bitcoin / Spark |
getChainKind(network) | the backend's chain family (evm / tron / bitcoin / spark) — every EVM network reports evm and srcChainId separates them |
getPriceTicker(symbol) | the ticker the price feed knows an asset by. Bitfinex quotes USD₮ as UST; asking for USDT returns no price and the asset silently drops out of the fiat total |
getFeeToken(config, gasMode) | what a fee is denominated in, so the number shown before signing carries the right symbol |
findAssetConfig(chain, srcChainId, symbol) | maps a backend transaction/coupon row back to a registry asset |
getNativeMaxTransferFee(network) | the runaway-gas ceiling for native-paid transfers, in that coin's base units |
The EVM chain ids and USD₮ addresses are declared once in
wdk.ts and imported by assets.ts, so the WDK
network config and the backend id mapping cannot drift apart.
UTL is in the registry too, pointing at
0x63dE…71AD on Sepolia — the same address
contract/deployments/11155111.json records.
Boot & navigation
index.js registers AppRoot from
src/app/App.tsx. AppRoot renders nothing until
authStore and biometryStore have hydrated from
the keychain, then mounts, in order:
SafeAreaProvider → RootStoreContext →
RootErrorBoundary → WdkAppProvider (worklet
bundle + wdkConfigs) → the navigation container, with a
Toast host outside. Inside App four hooks run
for their side effects only and render nothing:
useSyncAppState, useSyncWdkAppState,
useWalletSessionLock and
useLinkWalletAddresses.
The initial route comes from NavigationStore.bootRoute, which
is a pure derivation over the two hydrated stores:
not authenticated → SignIn
authenticated, no biometry enrolment → EnableBiometric
otherwise → BiometricUnlock
NavigationStore also holds the navigation ref and the active
route name, so imperative moves (goToBiometricUnlock,
goToDevMenu) can be triggered from outside React and skip
themselves when already on the target route — otherwise every return to the
foreground would re-reset the stack onto the same screen.
Screens
| Route | Purpose | Data source |
|---|---|---|
SignIn | Google Sign-In → POST /auth/google | backend |
EnableBiometric | enrol device biometrics, persist the preference | device |
WalletSetup | create-or-recover fork; on focus it probes what recovery actually exists and only shows the routes that do — Drive, device recovery key, phrase | backend + Drive + keychain |
CreateWallet | generate a 12-word phrase in the worklet, pick optional backups (This device / Google Drive), restore locally | WDK + backend + Drive |
RestoreWallet | 12-word grid input, local BIP-39 validation, import | WDK |
BiometricUnlock | re-unlock the WDK session after background | device |
Home | fiat total, per-network asset rows, quick actions; re-reads balances and prices on focus, not just on mount | WDK + backend |
AssetDetail | one asset, its transaction rows | WDK + GET /transactions |
Receive | derived address + a real QR of the EIP-681 / bare-address payload | WDK |
Send | amount entry, live fee quote, destination validation | WDK |
ApproveTransaction | transparent-modal sheet: fee, biometric gate, broadcast, backend report | WDK + backend |
ScanToPay | full-screen expo-camera QR scanner; a payload with an amount goes straight to the approve sheet, one without to Send | device camera |
PaymentSuccess | post-payment confirmation with the broadcast hash | local |
Rewards | claimable UTL total + coupon list, re-read on focus and pull-to-refresh | GET /coupons |
ClaimCoupon | code entry, EIP-712 SafeMessage signature, claim submission and status | claims API |
WalletSettings | wallet management, view recovery phrase, create or repair the device / Drive backups, sign out | WDK + backend + Drive |
DevMenu | __DEV__-only modal; errors playground | — |
In development, DevSettings also registers two shake-menu
items: Dev Menu and Clear all cached data, the latter
signing out, resetting biometry and deleting every known wallet plus the
default id (which may still hold secure-storage material even when the
in-memory list is empty), then reloading.
Stores
A single RootStore wires the domain stores together and is
provided through RootStoreContext; components read it with
useStore() and are wrapped in observer() with a
named function.
| Store | Holds |
|---|---|
AuthStore | the session (access + refresh token + user), Google sign-in, refresh, sign-out; installs the auth bridge into the HTTP client |
BiometryStore | isAvailable / isEnrolled / isHydrated, prompt + outcome resolution |
WdkAppStore | mirror of the WDK app state (NO_WALLET / LOCKED / READY) |
AppStateStore | foreground/background, fed by the single native AppState listener in useSyncAppState |
SecretsStore | the ciphertext half of the backup: remote wallet existence, write-once upload of the encrypted seed and entropy, read-back of the recovery bundle |
WalletBackupStore | the key half plus orchestration: save to device keychain, back up to Google Drive, probe what backups and recovery routes exist, load credentials back for a restore |
NavigationStore | navigation ref, active route, bootRoute |
WalletStore | the asset registry as display rows, three TypedRequests (transactions, coupons, prices), linkedEvmAddress, the local-broadcast list and the failed-report queue |
Domain objects live under shared/store/models
(asset, coupon, transaction,
wallet) with display helpers beside them. Async calls go
through the generic MobX wrapper Request<R>
(shared/store/request.ts, typedRequest.ts), which
is what the screens read for their loading and error states.
WalletStore deliberately does not own
balances. Those come from useAssetBalances(), which wraps
WDK's useBalancesForWallet for account 0 and reduces the
result into an assetId → base-unit string map plus a
per-asset error map — one network failing must not blank the
others, and a row that just shows a dash is invisible, so the reason is
logged rather than swallowed. Prices come from
GET /pricing/live; an asset the feed cannot quote drops out
of the fiat total instead of counting as zero, and is named in the
console.
Two lists exist for one reason each. localTransactions holds
broadcasts this device made that the backend has not listed yet — the
transactions getter unions them and drops one as soon as the
same hash comes back from the API, so a send appears in history
immediately without ever being shown twice. pendingReports
holds POST /transactions calls that failed: the money already
moved, so a failed report must never surface to the user, and it is
retried on the next wallet-ready pass with the txHash as its
idempotency key.
Backend client
shared/api/httpClient.ts is one axios instance against
API_BASE_URL with a 15 s timeout, a request interceptor that
attaches Authorization: Bearer <accessToken>, and a
response interceptor that handles token expiry.
- Single-flight refresh. The first 401 stores its refresh promise; concurrent 401s await the same promise instead of firing parallel refreshes against an already-rotated token.
-
Retry once. The original request is replayed with the
new token;
_retriedprevents a loop. -
Exempt paths.
/auth/refresh(a 401 here means the session is truly dead → sign out) and/auth/google(a 401 is a failed login; there is nothing to refresh). -
Error normalisation.
toApiError()unwraps the backend'sGlobalExceptionFilterenvelope into anApiErrorcarryingstatusCodeanderrorCode.
What a request actually does
The interesting case is an expired access token in the middle of a screen
that is already loading. Step through it — the middle lane is
httpClient, which is where both interceptors live.
Dashed messages are the recovery path — they only happen on a 401. Click a lane for what it owns.
Endpoint explorer
Every backend endpoint this app touches or will touch. Filter by whether it is wired today, and click one for the call site and what it expects.
Pick an endpoint.
Wallet lifecycle
useWallet() (shared/lib/hooks/wallet) is the
app-facing surface of the WDK wallet manager. It is intentionally
single-wallet: every operation targets DEFAULT_WALLET_ID, so
callers never pass a wallet id, and the mnemonic word count is folded into
generateMnemonic.
| Operation | Does |
|---|---|
generateMnemonic() | fresh BIP-39 phrase from worklet entropy |
restoreWallet(mnemonic) | import into the default wallet and set it active |
unlock() | unlock the default wallet, prompting device biometrics |
getMnemonic() | read the stored phrase behind a biometric prompt |
getSeedAndEntropyFromMnemonic(mnemonic) | derive the encrypted entropy and seed blobs plus their encryption key, inside the worklet — the input to the backup call |
getWalletCredentials() | the backup triple — { encryptionKey, encryptedSeed, encryptedEntropy } — read out of secure storage and validated; anything malformed is backup_unavailable rather than an uploaded half-blob |
restoreWalletCredentials(credentials) | the reverse: write the triple into secure storage under the default id and unlock, refusing when a wallet exists and rolling the write back on any failure |
deleteWallet(id?) | delete a wallet and all associated data |
hasPersistedWallet(), getStateStatus(), getWallets() | live reads, exposed as functions so async callers do not capture a stale value across an await |
When the WDK is busy or errored the hook alerts the user and throws
WdkNotReadyError, which callers treat as “abort silently” —
the user has already been told.
Address linking
shared/lib/walletLinking.ts is small and load-bearing. On
every transition into READY — not once per session —
useLinkWalletAddresses() derives account 0 across
SUPPORTED_NETWORKS, posts them to
POST /wallets, reads back GET /wallets, and
stores the EVM address the backend ended up with in
walletStore.linkedEvmAddress. Then it flushes any queued
transaction reports.
-
One record per chain family, not per network. The
backend keys a wallet on
chain, so the three EVM networks collapse into a singleevmrecord; sending all three is a 400DUPLICATE_CHAINthat fails the whole request. First wins, andSUPPORTED_NETWORKSlistsethereumfirst among the EVM ones. -
Re-run on every
READY. A wallet created or restored after sign-in derives new addresses under the same wallet id; an “already linked” flag would leave it unlinked until the next cold start. Re-posting the same address is a no-op on the backend. - Never throws. Linking is best-effort — the read-back is what decides whether paying is safe, so a failed link is swallowed and the address comparison speaks instead.
This is the single most consequential call in the app. The backend
recognises a payer by sender address alone: a transfer
from any address it does not have on file is recorded
ignored and never reprocessed — no coupon, no retry, money
spent. So ApproveTransactionScreen refuses an EVM send
outright when linkedEvmAddress is missing, or differs from
the address this wallet signs with (compared case-insensitively; EVM
addresses differ only by EIP-55 checksum casing). A missing link offers
a “Link wallet” retry; a mismatch does not, because that is a
409 on the backend with no reset endpoint and retrying would only fail
again.
Paying: QR, fees, broadcast
Reading a QR
ScanToPayScreen runs a real expo-camera
viewfinder restricted to QR codes, with a permission state that
distinguishes “not asked yet” from “denied, go to Settings”. The camera
keeps firing while the navigation animation runs, so the first accepted
code latches a ref and every later frame is ignored.
shared/lib/paymentUri.ts turns the payload into something
this wallet can actually sign:
0xA4f2… plain address
ethereum:0xA4f2… EIP-681, native
ethereum:0xA4f2…@42161?value=1000000 EIP-681 with an amount
ethereum:0xTOKEN@42161/transfer?address=0xA4f2…&uint256=25000000 ERC-20
bitcoin:bc1q…?amount=0.001 BIP-21
An unrecognised or unpayable payload is rejected with a message rather
than half-parsed. Amount units differ by scheme and the parser knows it:
EIP-681 value/uint256 are already base units,
BIP-21 amount is decimal BTC. A payload carrying an amount is
a complete payment request and goes straight to the approve sheet; one
without goes to Send for the user to fill in. The same module
builds the wallet's own receive payload, so
ReceiveScreen renders a QR other wallets can read.
Who pays the gas
useAssetTransfer(assetId) is the one transfer surface, and it
has a decision the ERC-4337 accounts make possible:
| Mode | Means |
|---|---|
native | spend the chain's own coin, like any ordinary wallet. Requires restating transferMaxFee as the wei-denominated NATIVE_MAX_TRANSFER_FEE |
token | route through the Candide paymaster, which fronts the coin and bills the account in USD₮ — no gas coin needed at all, but the account must cover transfer and fee in USD₮ |
The hook tries native first when the account holds any gas
coin and falls back to token otherwise, quoting under each in
turn until one is accepted. A send is never retried in the other
mode: a broadcast that failed late may still have reached the
bundler, and a second attempt would be a second payment — the caller
passes back the mode its quote came under, so the user pays the fee they
were shown. The quote path does not check the fee cap, so a stale cap only
shows up at send time, which is why the cap lives in config rather than in
the call.
After the broadcast
ApproveTransactionScreen gates the signature on
biometryStore.verify() — with three distinct failure
messages, because “no biometrics enrolled”, “permission revoked” and “the
scan did not match” need three different actions from the user. Once the
money has moved, the order of what follows is deliberate: record local
history first (it survives everything after it), then refresh balances,
then report to the backend, then navigate.
The balance refresh is wallet-level, not token-level.
The screens read one aggregated query keyed
[...byWallet(walletId, 0), 'all'] and TanStack matches keys
by prefix — a byToken key is longer, so invalidating it
leaves the aggregate untouched and every screen stale. The gas coin moved
too when the fee was paid natively, so the whole wallet is refetched
anyway.
Rewards & the claim signature
RewardsScreen re-reads GET /coupons on every
focus and on pull-to-refresh — coupons are accrued server-side from
confirmed payments, so a cached list is a wrong list. The claimable total
is summed over utlAmount in bigint, never a
float.
useClaimCoupon() is where the app's one cryptographic
contribution to the money path lives:
GET /claims/challenge?coupon=CODE → { challengeId, nonce, message,
verifyingContract, chainId }
sign EIP-712 SafeMessage { message: hashMessage(challenge.message) }
under domain { chainId, verifyingContract }
POST /claims { challengeId, signature, code } Idempotency-Key: uuid
Why not personal_sign. The wallet's address
is a Safe (ERC-4337), not an EOA. Signing the challenge as a plain string
yields a signature that recovers to the Safe's owner key, which
is not the payout address the backend compares against — the result is
OWNERSHIP_PROOF_INVALID. Wrapping it as an EIP-712
SafeMessage makes the signature verifiable through ERC-1271
(isValidSignature on the Safe), which is what actually
proves control of that address. The message body is the EIP-191 hash of
the challenge, matching what the Safe's fallback handler hashes
internally; the backend recomputes it from the string it issued.
The domain comes from the challenge, never from local config: a Safe
answers isValidSignature only on the chain it was linked
from, which is not necessarily the chain the reward is paid out on. The
backend still accepts a plain EOA signature — verifyOwnership
tries ecrecover first and falls back to the on-chain check.
signTypedData is not part of WDK's published
useAccount surface, so both this hook and the transfer hook
reach the account method through
AccountService.callAccountMethod, which forwards any method by
name to the worklet. That escape hatch is documented at both call sites and
should disappear when WDK publishes the typed surface.
Backup & recovery
The wallet always lives on the device. On top of that the user may
create backups, and the whole design is one rule: the ciphertext
and the key that opens it never sit in the same place. The
backend holds the encrypted seed and entropy and nothing else — the
metadata it stores is { version: 1 }. The 32-byte
encryption key goes to one of two places the backend cannot read.
| Backup | Ciphertext | Key |
|---|---|---|
| This device | POST /secrets/seed + /secrets/entropy |
device keychain, service com.wdkqualification.walletBackupKey.v1.<userId>, WHEN_UNLOCKED_THIS_DEVICE_ONLY — survives a reinstall-free wipe of app data, not a new phone |
| Google Drive | same backend blobs | wallet-backup-key.json in the Drive appDataFolder: { schemaVersion: 1, encryptionKey }, under the drive.appdata scope only — a hidden per-app folder no other app and no file picker can see |
| Recovery phrase | nothing stored anywhere. The 12 words the user wrote down, imported straight into the worklet | |
Both backups are optional and independent — the create screen
offers them as two checkboxes, and WalletSettings can add
or repair either one later. Keying the local key by userId
is what makes the device backup account-scoped rather than
device-global.
Writing a backup
WalletBackupStore.saveLocalBackup() and
backupToCloud() are the same three moves in different
order:
-
for Drive,
authorize(true)first — an interactive scope grant, because failing after the ciphertext is uploaded is a worse place to fail; -
secretsStore.ensureRemoteWalletSecrets()reads the stored blobs and posts only what is missing. A stored blob that differs from the one being backed up throws rather than overwrites. The backend upserts on(user_id, kind)and would take the write, and it now hasDELETEroutes the client never calls — the restraint is entirely the client's, and it is what keeps the first wallet an account backed up recoverable; -
write the key, then read both halves back and compare them
against what was meant to be stored. A backup that reports
success without proving it is readable is the only failure mode that
matters here, so the store never trusts its own writes — a mismatch is
backup_unavailable.
Every value crossing a boundary is validated as canonical base64 of the
right size (secretValidation.ts): a key is exactly 32
bytes, a ciphertext 29–10240. That check runs on what the worklet
hands over, on what the backend returns, and on what comes out of
Drive.
Recovering
WalletSetupScreen calls
checkRecoveryOptions() on every focus and shows only the
routes that actually resolve: it asks the backend whether both blobs
exist, checks the keychain for this user's key, and — only if the blobs
are there — probes Drive non-interactively, so merely opening
the screen never throws a Google consent sheet at the user.
Recovering runs behind a biometric prompt, then
loadBackupCredentials(source) fetches the key from the
chosen place and the bundle from the backend, and
restoreWalletCredentials() writes seed, entropy and key
straight into WDK secure storage under
DEFAULT_WALLET_ID and unlocks. It refuses outright if a
wallet already exists on the device, and if any step including the
unlock fails it deletes what it wrote — a half-written wallet would be
an unopenable one that then blocks every later restore.
Restoring from the phrase (RestoreWalletScreen) is
deliberately independent of all of this: it validates the BIP-39 phrase
locally and imports it. Nothing is checked against the server, and no
mnemonicHash is stored any more — a verifier that can lock
out a user holding the correct phrase is worse than no verifier. If
secure storage already holds a wallet (a keychain entry that outlived a
reinstall), the screen offers to open it rather than overwrite it.
“Start from zero” exists for the case where the account's backup belongs to a wallet the user no longer has: it creates a brand-new wallet with both backup tiles hidden, because the account's blobs cannot be replaced. That wallet is phrase-only, and the screen says so.
The Drive layer
GoogleDriveKeyProvider talks to the Drive REST API through
an injectable transport, and treats every response as hostile: the file
listing is paged with hard caps (10 pages, 20 matches, 64 KB), the
envelope is capped at 8 KB and checked both by
content-length and by the real UTF-8 length of what
arrived, and the parser accepts only an exact key set — a superset, a
missing field or a bad key length is invalid_envelope.
Duplicate wallet-backup-key.json files (Drive allows them)
must be byte-identical or the read fails remote_invariant
rather than picking a winner; a write updates all of them.
Authorization is single-flight in
GoogleDriveAuthorization: concurrent callers share one
promise, and a silent probe that lands while an interactive one is
pending waits for it instead of racing a second consent sheet. Outcomes
are five explicit states — authorized,
cancelled, denied, signed_out,
unavailable — because “cancel” and “no Play Services” need
different words in the UI. A 401 clears the cached access token and
retries once; a second 401 is unauthorized, a 403 is
permission_denied.
Everything funnels through toWalletBackupError(), which
maps provider errors onto eight user-facing codes with fixed safe
messages. No Drive or backend detail reaches a dialog — the user is told
what to do, not what broke.
This closes the security gap earlier versions of this page
carried. The client used to ship encryptionKey
inside the metadata it posted to /secrets/*,
next to the ciphertext it decrypts, so a stolen backend database
yielded both halves. The metadata is now
{ version: 1 } and the key never reaches the backend at
all. What remains is a trust shift rather than a hole: the Drive route
trusts the user's Google account, and a passphrase-derived key would
remove even that — see the roadmap below.
Session lock & biometrics
useWalletSessionLock() reacts to appStateStore
(there is exactly one native AppState listener, in
useSyncAppState; the reaction runs outside React, so no
re-render):
-
going to
backgroundwhileREADY→lockWdkWalletSession(); -
becoming
activewhileLOCKED→navigationStore.goToBiometricUnlock().
Lock on background only, never
inactive: iOS uses inactive for the system Face
ID sheet during in-app biometry (e.g. viewing the recovery phrase), so
locking there would fight the very prompt that is authenticating the user.
Re-unlock is driven off wdkAppStore.status rather than a local
“did we lock” flag, which is also robust to iOS's
background → inactive → active return path. Cold start is
already LOCKED after rehydrate and no AppState
change fires, so it stays out of this path.
lockWdkWalletSession() uses the internal WDK API to reset the
worklet lifecycle and set walletLoadingState to
not_loaded while keeping
activeWalletId, so unlock() can run
not_loaded → loading → ready. The public
useWalletManager().lock() is for logout: it clears
activeWalletId and breaks unlock(), so session
lock must not call it.
Biometrics go through expo-local-authentication.
isBiometricAvailable() requires both hardware and an enrolled
credential before prompting. The failure code is kept rather than collapsed
to a boolean, so a revoked app permission
(permission-denied → route to Settings) is distinguishable
from a transient cancel or lockout (failed → just retry).
Sessions and the biometry preference are stored in the device keychain
under separate services (react-native-keychain), and a corrupt
payload is dropped so callers fall back to a clean state.
Conventions & tests
-
Single-line
//comments everywhere, including multi-line notes and API documentation. No block or JSDoc comments. - MobX for reactive state and cross-component eventing — never hand-rolled pub/sub. Four kinds of store: root, feature, domain, domain object.
-
observer()with a named function, so components have a display name in DevTools and stack traces. - Prettier: single quotes, trailing commas,
arrowParens: avoid. Noformatscript — runnpx prettier --write .. -
Review convention: the user leaves
AI-REVIEW:comments inline; the answer goes directly below asAI-ANSWER:, and the original comment is never removed. -
Scripts:
npm start(WDK watch + Metro),npm run ios/android,npm run typecheck,npm run lint,npm test.
Tests are 357 across 41 Jest suites, sitting beside the code they cover
(foo.ts → foo.test.ts), and the gate is now
the backend's: 90 % statements, branches, functions and
lines, enforced by coverageThreshold. Coverage is
collected from the business-logic layers only —
shared/lib, api, store,
config — with screens, UI, barrels, type-only modules, the
static WDK config and the WDK-SDK glue hooks excluded on purpose, so the
number measures logic rather than rendering. npm run
test:coverage prints the report plus an average via
scripts/coverage-average.js, and a husky
pre-push hook runs the suites related to what the branch
changed.
The dense parts are the money- and key-shaped ones:
paymentUri (every QR shape above, including the base-unit
vs decimal-BTC trap), units, and the whole backup path —
WalletBackupStore, SecretsStore,
googleDrive, googleDriveAuthorization,
driveKeyEnvelope, localBackupKeyStorage,
walletBackupError. One of those assertions is worth naming:
SecretsStore.test.ts asserts that nothing serialized to
/secrets/* ever matches
/encryptionKey|mnemonicHash|mnemonic/i — the old leak is a
failing test now, not a comment.
Gaps & next steps
-
iCloud as a third key store. Google Drive is shipped,
and it is the wrong default for an iPhone user who never signs into
Google. The work is one more
CloudKeyProviderimplementation over CloudKit's private database — the interface (authorize/getEncryptionKey/putEncryptionKey) and the envelope format already exist precisely so the store does not learn a second provider's vocabulary, and the outcome and error codes were written provider-neutral. What is genuinely new is the platform split: iCloud on iOS, Drive on Android, both on neither. -
Multi-account support. Everything below
useWallet()is hardcoded toDEFAULT_WALLET_ID, and the backend stores one blob per kind per user — one wallet's worth — which is why “start from zero” today produces a wallet that can never be backed up. Real support means a wallet id in the secure-storage calls, an account switcher over the WDK wallet list, a keyed backup slot on both the backend and the key stores, and an address-linking model that is per wallet rather than per user. - Passphrase-derived backup keys. The key no longer reaches the backend, so a stolen database is inert — but the Drive route still trusts the user's Google account, and the device route trusts the keychain. Deriving the key from a user passphrase would make every store equally untrusted.
-
Poll
GET /claims/:id.ClaimCouponScreenshows the status from the 202 and stops, so “Claim submitted” never becomes “The UTL is in your wallet” without leaving and returning.claimsApi.get()is already there. -
Read
GET /configfor the cashback rate, confirmation depths and contract addresses instead of hardcoding UTL's address in the asset registry. -
Persist
pendingReports. The failed-report queue is in memory, so a reload before the retry loses the report — the transfer is still on chain and the indexer still finds it, but confirmation tracking starts late. - Paginate. Transactions and coupons read the first page only; both endpoints return a cursor nobody follows yet.
- A way to exercise the worklet paths Jest cannot import. The 90 % gate covers the logic layers; the WDK-SDK glue hooks are excluded from coverage because there is nothing to import them into.