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. This page describes what exists today — including which screens still run on in-memory demo data.
Current integration status. Authentication, biometrics,
the WDK wallet lifecycle (generate / restore / unlock / delete) and the
encrypted backup to /secrets/* are wired to the real
backend. Balances, transactions, coupons and claiming are still served
from fixtures in WalletStore — the backend endpoints for all
of them exist and are simply not consumed yet.
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 |
|---|---|---|
spark | @tetherto/wdk-wallet-spark | network: MAINNET |
ethereum | @tetherto/wdk-wallet-evm-erc-4337 | chainId 11155111 (Sepolia), Candide bundler + paymaster, USD₮ paymaster token |
arbitrum | @tetherto/wdk-wallet-evm-erc-4337 | chainId 42161 |
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, so transfers can be paid in USD₮ rather than
native gas.
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.
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-restore fork | — |
CreateWallet | generate a 12-word phrase in the worklet, back it up, restore locally | WDK + backend |
RestoreWallet | 12-word grid input, verify against the stored hash, import | WDK + backend |
BiometricUnlock | re-unlock the WDK session after background | device |
Home | total fiat balance + asset rows | demo state |
AssetDetail | one asset, its transaction rows | demo state |
Receive | address / QR placeholder | demo state |
Send | amount entry for one asset | demo state |
ApproveTransaction | transparent-modal confirmation sheet | demo state |
ScanToPay | full-screen merchant payment flow; records a payment and mints a local coupon at 5 % | demo state |
PaymentSuccess | post-payment confirmation | demo state |
Rewards | claimable cashback total + coupon list | demo state |
ClaimCoupon | two-segment WDK-XXXX-XX code entry and claim | demo state |
WalletSettings | wallet management, view recovery phrase, sign out | WDK |
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 | remote wallet existence, backup upload, mnemonic matching |
NavigationStore | navigation ref, active route, bootRoute |
WalletStore | demo assets, transactions, coupons and their mutations |
Domain objects live under shared/store/models
(asset, coupon, transaction,
wallet) with display helpers beside them. Async calls are
meant to go through the generic MobX wrapper Request<R>
(shared/store/request.ts, typedRequest.ts).
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 |
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.
Backup & restore
Create (CreateWalletScreen):
- generate the phrase in the worklet and show it for the user to write down;
-
secretsStore.hasRemoteWallet()— the server is the source of truth, so an account that already has a wallet is steered to restore rather than given a second one; -
derive the encrypted entropy and seed blobs, then
POST /secrets/seedandPOST /secrets/entropywithmetadata = { mnemonicHash, encryptionKey, version: 1 }— before persisting locally, so the server stays authoritative even if the on-device write later fails; restoreWallet(mnemonic)locally, then reset the stack toHome.
Restore (RestoreWalletScreen):
-
secretsStore.matchMnemonic()comparesSHA-256(normalised mnemonic)against themnemonicHashin the stored blobs' metadata; - if no stored blob carries a comparable verifier (an older client), the restore is allowed rather than blocked — a missing verifier must not lock out a user who typed the correct phrase;
restoreWallet(mnemonic), then unlock.
Normalisation before hashing is trim → lowercase → collapse
whitespace, so the verifier is stable across keyboards.
The client uploads encryptionKey inside
metadata, next to the ciphertext it decrypts. The
backend treats metadata as free-form and stores it verbatim,
so a compromise of the backend database yields both halves and the
encryption of the seed backup provides no protection. The backend
documentation is explicit that the client-side KDF and the passphrase
behind it are the only thing between a stolen database and every user's
funds — this client currently does not provide one. Deriving the key from
a user passphrase and never transmitting it is the fix.
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
-
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.
Gaps & next steps
-
Replace
WalletStorefixtures with the API.GET /balances,GET /transactions,GET /coupons,GET /claims/previewalready exist and match the shapes the screens render. -
Wire the real claim flow. Today
ClaimCouponScreenmutates local state. The real path isGET /claims/challenge?coupon=CODE→personal_sign(message)with the WDK EVM signer →POST /claimswith anIdempotency-Key→ pollGET /claims/:idfor attestation progress. -
Link addresses.
POST /walletsis not called yet; without it no payment can be attributed to the user and no coupon is ever accrued. - Derive the backup encryption key from a user passphrase and stop uploading it — see the warning above.
-
Real QR scanning for
ScanToPayand a real QR renderer forReceive(currentlyQrPlaceholder). -
Test coverage is a single smoke test
(
__tests__/App.test.tsx).