Mobile app

React Native + WDK worklet

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:

Regeneration is automatic in every path that could invalidate it:

TriggerMechanism
npm installpostinstallnpm run wdk:bundle
while editingnpm start runs wdk:watch (onchange) alongside Metro
on commitlint-staged rule when wdk.config.js is staged
after pull / branch switchhusky post-merge and post-checkout
manualnpm 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.

NetworkPackageRuntime config
spark@tetherto/wdk-wallet-sparknetwork: MAINNET
ethereum@tetherto/wdk-wallet-evm-erc-4337chainId 11155111 (Sepolia), Candide bundler + paymaster, USD₮ paymaster token
arbitrum@tetherto/wdk-wallet-evm-erc-4337chainId 42161
polygon@tetherto/wdk-wallet-evm-erc-4337chainId 137, safeModulesVersion 0.3.0
tron@tetherto/wdk-wallet-tronTronGrid, 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: SafeAreaProviderRootStoreContextRootErrorBoundaryWdkAppProvider (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

RoutePurposeData source
SignInGoogle Sign-In → POST /auth/googlebackend
EnableBiometricenrol device biometrics, persist the preferencedevice
WalletSetupcreate-or-restore fork
CreateWalletgenerate a 12-word phrase in the worklet, back it up, restore locallyWDK + backend
RestoreWallet12-word grid input, verify against the stored hash, importWDK + backend
BiometricUnlockre-unlock the WDK session after backgrounddevice
Hometotal fiat balance + asset rowsdemo state
AssetDetailone asset, its transaction rowsdemo state
Receiveaddress / QR placeholderdemo state
Sendamount entry for one assetdemo state
ApproveTransactiontransparent-modal confirmation sheetdemo state
ScanToPayfull-screen merchant payment flow; records a payment and mints a local coupon at 5 %demo state
PaymentSuccesspost-payment confirmationdemo state
Rewardsclaimable cashback total + coupon listdemo state
ClaimCoupontwo-segment WDK-XXXX-XX code entry and claimdemo state
WalletSettingswallet management, view recovery phrase, sign outWDK
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.

StoreHolds
AuthStorethe session (access + refresh token + user), Google sign-in, refresh, sign-out; installs the auth bridge into the HTTP client
BiometryStoreisAvailable / isEnrolled / isHydrated, prompt + outcome resolution
WdkAppStoremirror of the WDK app state (NO_WALLET / LOCKED / READY)
AppStateStoreforeground/background, fed by the single native AppState listener in useSyncAppState
SecretsStoreremote wallet existence, backup upload, mnemonic matching
NavigationStorenavigation ref, active route, bootRoute
WalletStoredemo 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.

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.

secretsApi.getEntropy() GET /secrets/entropy · Authorization: Bearer … 401 — access token expired POST /auth/refresh — bare client, single-flight new access + refresh → keychain retry once, new Bearer (_retried = true) 200 { entropies: [ … ] } Screen → store observer + Request<R> httpClient interceptors + auth bridge Backend API /api · JWT guard

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.

    OperationDoes
    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):

    1. generate the phrase in the worklet and show it for the user to write down;
    2. 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;
    3. derive the encrypted entropy and seed blobs, then POST /secrets/seed and POST /secrets/entropy with metadata = { mnemonicHash, encryptionKey, version: 1 }before persisting locally, so the server stays authoritative even if the on-device write later fails;
    4. restoreWallet(mnemonic) locally, then reset the stack to Home.

    Restore (RestoreWalletScreen):

    1. secretsStore.matchMnemonic() compares SHA-256(normalised mnemonic) against the mnemonicHash in the stored blobs' metadata;
    2. 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;
    3. 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):

    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

    Gaps & next steps