Mobile app

Client Architecture & Key Custody

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:

Regeneration is automatic in every path that could invalidate it:

TriggerMechanism
npm installpostinstall → npm 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
bitcoin@tetherto/wdk-wallet-btca 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-sparknetwork: MAINNET
ethereum@tetherto/wdk-wallet-evm-erc-4337chainId 11155111 (Sepolia), publicnode RPC, Candide bundler + paymaster, USD₮ paymaster token — the reward chain
arbitrum@tetherto/wdk-wallet-evm-erc-4337chainId 42161 (mainnet) — the payment chain the demo merchant settles on
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 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.

HelperAnswers
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

RoutePurposeData source
SignInGoogle Sign-In → POST /auth/googlebackend
EnableBiometricenrol device biometrics, persist the preferencedevice
WalletSetupcreate-or-recover fork; on focus it probes what recovery actually exists and only shows the routes that do — Drive, device recovery key, phrasebackend + Drive + keychain
CreateWalletgenerate a 12-word phrase in the worklet, pick optional backups (This device / Google Drive), restore locallyWDK + backend + Drive
RestoreWallet12-word grid input, local BIP-39 validation, importWDK
BiometricUnlockre-unlock the WDK session after backgrounddevice
Homefiat total, per-network asset rows, quick actions; re-reads balances and prices on focus, not just on mountWDK + backend
AssetDetailone asset, its transaction rowsWDK + GET /transactions
Receivederived address + a real QR of the EIP-681 / bare-address payloadWDK
Sendamount entry, live fee quote, destination validationWDK
ApproveTransactiontransparent-modal sheet: fee, biometric gate, broadcast, backend reportWDK + backend
ScanToPayfull-screen expo-camera QR scanner; a payload with an amount goes straight to the approve sheet, one without to Senddevice camera
PaymentSuccesspost-payment confirmation with the broadcast hashlocal
Rewardsclaimable UTL total + coupon list, re-read on focus and pull-to-refreshGET /coupons
ClaimCouponcode entry, EIP-712 SafeMessage signature, claim submission and statusclaims API
WalletSettingswallet management, view recovery phrase, create or repair the device / Drive backups, sign outWDK + 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.

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
SecretsStorethe ciphertext half of the backup: remote wallet existence, write-once upload of the encrypted seed and entropy, read-back of the recovery bundle
WalletBackupStorethe 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
NavigationStorenavigation ref, active route, bootRoute
WalletStorethe 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.

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
    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.

    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:

    ModeMeans
    nativespend the chain's own coin, like any ordinary wallet. Requires restating transferMaxFee as the wei-denominated NATIVE_MAX_TRANSFER_FEE
    tokenroute 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.

    BackupCiphertextKey
    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:

    1. for Drive, authorize(true) first — an interactive scope grant, because failing after the ciphertext is uploaded is a worse place to fail;
    2. 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 has DELETE routes the client never calls — the restraint is entirely the client's, and it is what keeps the first wallet an account backed up recoverable;
    3. 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):

    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

    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