From 12cde1648d7e2fb6157cecb22b2d8391d6e03431 Mon Sep 17 00:00:00 2001 From: temrjan Date: Wed, 8 Jul 2026 11:01:36 +0500 Subject: [PATCH 1/7] chore(mobile): bump core submodule to validate_mnemonic + ubrn regen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps vendor/core bc70f07 -> 13e2330 (core#85: validate_mnemonic) and regenerates the uniffi bindings (ubrn build android --and-generate, arm64-v8a). Machine-generated diff only — no hand-written code — so the manual A4 commits that follow (phraseInput, store, screen) stay reviewable on their own. validateMnemonic(phrase: string): void /*throws*/ now present in generated TS; tsc clean; old jest 150/150 (regen broke nothing). --- .../cpp/generated/rustok_mobile_bindings.cpp | 39 +++++++++++++++++ .../cpp/generated/rustok_mobile_bindings.hpp | 2 + .../generated/rustok_mobile_bindings-ffi.ts | 5 +++ .../src/generated/rustok_mobile_bindings.ts | 42 +++++++++++++++++++ vendor/core | 2 +- 5 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/react-native-rustok-bridge/cpp/generated/rustok_mobile_bindings.cpp b/packages/react-native-rustok-bridge/cpp/generated/rustok_mobile_bindings.cpp index 4fbdf9c..590a8cc 100644 --- a/packages/react-native-rustok-bridge/cpp/generated/rustok_mobile_bindings.cpp +++ b/packages/react-native-rustok-bridge/cpp/generated/rustok_mobile_bindings.cpp @@ -179,6 +179,10 @@ extern "C" { RustBuffer word_count, RustCallStatus *uniffi_out_err ); + void uniffi_rustok_mobile_bindings_fn_func_validate_mnemonic( + RustBuffer phrase, + RustCallStatus *uniffi_out_err + ); RustBuffer ffi_rustok_mobile_bindings_rustbuffer_alloc( uint64_t size, RustCallStatus *uniffi_out_err @@ -378,6 +382,8 @@ extern "C" { ); uint16_t uniffi_rustok_mobile_bindings_checksum_func_generate_wallet( ); + uint16_t uniffi_rustok_mobile_bindings_checksum_func_validate_mnemonic( + ); uint16_t uniffi_rustok_mobile_bindings_checksum_method_ffiwallet_address( ); uint16_t uniffi_rustok_mobile_bindings_checksum_method_ffiwallet_analyze( @@ -1942,6 +1948,14 @@ NativeRustokMobileBindings::NativeRustokMobileBindings( return this->cpp_uniffi_rustok_mobile_bindings_fn_func_generate_wallet(rt, thisVal, args, count); } ); + props["ubrn_uniffi_rustok_mobile_bindings_fn_func_validate_mnemonic"] = jsi::Function::createFromHostFunction( + rt, + jsi::PropNameID::forAscii(rt, "ubrn_uniffi_rustok_mobile_bindings_fn_func_validate_mnemonic"), + 1, + [this](jsi::Runtime &rt, const jsi::Value &thisVal, const jsi::Value *args, size_t count) -> jsi::Value { + return this->cpp_uniffi_rustok_mobile_bindings_fn_func_validate_mnemonic(rt, thisVal, args, count); + } + ); props["ubrn_ffi_rustok_mobile_bindings_rust_future_poll_u8"] = jsi::Function::createFromHostFunction( rt, jsi::PropNameID::forAscii(rt, "ubrn_ffi_rustok_mobile_bindings_rust_future_poll_u8"), @@ -2334,6 +2348,14 @@ NativeRustokMobileBindings::NativeRustokMobileBindings( return this->cpp_uniffi_rustok_mobile_bindings_checksum_func_generate_wallet(rt, thisVal, args, count); } ); + props["ubrn_uniffi_rustok_mobile_bindings_checksum_func_validate_mnemonic"] = jsi::Function::createFromHostFunction( + rt, + jsi::PropNameID::forAscii(rt, "ubrn_uniffi_rustok_mobile_bindings_checksum_func_validate_mnemonic"), + 0, + [this](jsi::Runtime &rt, const jsi::Value &thisVal, const jsi::Value *args, size_t count) -> jsi::Value { + return this->cpp_uniffi_rustok_mobile_bindings_checksum_func_validate_mnemonic(rt, thisVal, args, count); + } + ); props["ubrn_uniffi_rustok_mobile_bindings_checksum_method_ffiwallet_address"] = jsi::Function::createFromHostFunction( rt, jsi::PropNameID::forAscii(rt, "ubrn_uniffi_rustok_mobile_bindings_checksum_method_ffiwallet_address"), @@ -2658,6 +2680,16 @@ jsi::Value NativeRustokMobileBindings::cpp_uniffi_rustok_mobile_bindings_fn_func return uniffi::rustok_mobile_bindings::Bridging::toJs(rt, callInvoker, value); } +jsi::Value NativeRustokMobileBindings::cpp_uniffi_rustok_mobile_bindings_fn_func_validate_mnemonic(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) { + RustCallStatus status = uniffi::rustok_mobile_bindings::Bridging::rustSuccess(rt); + uniffi_rustok_mobile_bindings_fn_func_validate_mnemonic(uniffi::rustok_mobile_bindings::Bridging::fromJs(rt, callInvoker, args[0]), + &status + ); + uniffi::rustok_mobile_bindings::Bridging::copyIntoJs(rt, callInvoker, status, args[count - 1]); + + + return jsi::Value::undefined(); +} jsi::Value NativeRustokMobileBindings::cpp_ffi_rustok_mobile_bindings_rust_future_poll_u8(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) { ffi_rustok_mobile_bindings_rust_future_poll_u8(uniffi_jsi::Bridging::fromJs(rt, callInvoker, args[0]), uniffi::rustok_mobile_bindings::Bridging::fromJs(rt, callInvoker, args[1]), uniffi_jsi::Bridging::fromJs(rt, callInvoker, args[2]) ); @@ -3035,6 +3067,13 @@ jsi::Value NativeRustokMobileBindings::cpp_uniffi_rustok_mobile_bindings_checksu ); + return uniffi_jsi::Bridging::toJs(rt, callInvoker, value); +} +jsi::Value NativeRustokMobileBindings::cpp_uniffi_rustok_mobile_bindings_checksum_func_validate_mnemonic(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) { + auto value = uniffi_rustok_mobile_bindings_checksum_func_validate_mnemonic( + ); + + return uniffi_jsi::Bridging::toJs(rt, callInvoker, value); } jsi::Value NativeRustokMobileBindings::cpp_uniffi_rustok_mobile_bindings_checksum_method_ffiwallet_address(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) { diff --git a/packages/react-native-rustok-bridge/cpp/generated/rustok_mobile_bindings.hpp b/packages/react-native-rustok-bridge/cpp/generated/rustok_mobile_bindings.hpp index 90e9d64..f671d18 100644 --- a/packages/react-native-rustok-bridge/cpp/generated/rustok_mobile_bindings.hpp +++ b/packages/react-native-rustok-bridge/cpp/generated/rustok_mobile_bindings.hpp @@ -33,6 +33,7 @@ class NativeRustokMobileBindings : public jsi::HostObject { jsi::Value cpp_uniffi_rustok_mobile_bindings_fn_method_ffiwallet_sign_legacy_tx(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); jsi::Value cpp_uniffi_rustok_mobile_bindings_fn_method_ffiwallet_sign_message(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); jsi::Value cpp_uniffi_rustok_mobile_bindings_fn_func_generate_wallet(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); + jsi::Value cpp_uniffi_rustok_mobile_bindings_fn_func_validate_mnemonic(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); jsi::Value cpp_ffi_rustok_mobile_bindings_rust_future_poll_u8(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); jsi::Value cpp_ffi_rustok_mobile_bindings_rust_future_cancel_u8(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); jsi::Value cpp_ffi_rustok_mobile_bindings_rust_future_free_u8(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); @@ -82,6 +83,7 @@ class NativeRustokMobileBindings : public jsi::HostObject { jsi::Value cpp_ffi_rustok_mobile_bindings_rust_future_free_void(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); jsi::Value cpp_ffi_rustok_mobile_bindings_rust_future_complete_void(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); jsi::Value cpp_uniffi_rustok_mobile_bindings_checksum_func_generate_wallet(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); + jsi::Value cpp_uniffi_rustok_mobile_bindings_checksum_func_validate_mnemonic(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); jsi::Value cpp_uniffi_rustok_mobile_bindings_checksum_method_ffiwallet_address(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); jsi::Value cpp_uniffi_rustok_mobile_bindings_checksum_method_ffiwallet_analyze(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); jsi::Value cpp_uniffi_rustok_mobile_bindings_checksum_method_ffiwallet_export_keystore(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count); diff --git a/packages/react-native-rustok-bridge/src/generated/rustok_mobile_bindings-ffi.ts b/packages/react-native-rustok-bridge/src/generated/rustok_mobile_bindings-ffi.ts index 12198f8..8de8015 100644 --- a/packages/react-native-rustok-bridge/src/generated/rustok_mobile_bindings-ffi.ts +++ b/packages/react-native-rustok-bridge/src/generated/rustok_mobile_bindings-ffi.ts @@ -180,6 +180,10 @@ interface NativeModuleInterface { wordCount: Uint8Array, uniffi_out_err: UniffiRustCallStatus, ): Uint8Array; + ubrn_uniffi_rustok_mobile_bindings_fn_func_validate_mnemonic( + phrase: Uint8Array, + uniffi_out_err: UniffiRustCallStatus, + ): void; ubrn_uniffi_rustok_mobile_bindings_fn_constructor_ffiwallet_import_keystore( bytes: Uint8Array, password: Uint8Array, @@ -220,6 +224,7 @@ interface NativeModuleInterface { ): bigint; ubrn_ffi_rustok_mobile_bindings_uniffi_contract_version(): number; ubrn_uniffi_rustok_mobile_bindings_checksum_func_generate_wallet(): number; + ubrn_uniffi_rustok_mobile_bindings_checksum_func_validate_mnemonic(): number; ubrn_uniffi_rustok_mobile_bindings_checksum_constructor_ffiwallet_import_keystore(): number; ubrn_uniffi_rustok_mobile_bindings_checksum_constructor_ffiwallet_import_mnemonic(): number; ubrn_uniffi_rustok_mobile_bindings_checksum_method_ffiwallet_address(): number; diff --git a/packages/react-native-rustok-bridge/src/generated/rustok_mobile_bindings.ts b/packages/react-native-rustok-bridge/src/generated/rustok_mobile_bindings.ts index 235e5ff..9f54806 100644 --- a/packages/react-native-rustok-bridge/src/generated/rustok_mobile_bindings.ts +++ b/packages/react-native-rustok-bridge/src/generated/rustok_mobile_bindings.ts @@ -84,6 +84,40 @@ export function generateWallet( ); } +/** + * Validates a BIP-39 mnemonic phrase without deriving or storing anything. + * + * This is the import flow's phrase gate (Stage 2 A4): it answers only "is this + * a valid mnemonic?" and runs **no** keystore KDF. Re-using + * [`FfiWallet::import_mnemonic`] as a validator would cost a full Argon2id pass + * and collapse a bad-phrase error into the same [`FfiError::Wallet`] as a + * storage failure — the caller could not tell "wrong phrase" from "KDF failed". + * This returns the discriminable [`FfiError::InvalidInput`] instead, so the + * import screen can honestly say "this is not a valid recovery phrase". + * + * Word-count-agnostic: any valid BIP-39 length (12/15/18/21/24) is accepted, + * mirroring [`Phrase::from_phrase`]; the "12 words only" product rule lives in + * the UI, not the crypto layer. Input is normalized (trim / lowercase / + * whitespace-collapse) exactly as `from_phrase` does. + * + * # Errors + * + * Returns [`FfiError::InvalidInput`] if `phrase` is not a valid BIP-39 + * mnemonic. The message is static — the phrase is never echoed into it. + */ +export function validateMnemonic(phrase: string): void /*throws*/ { + uniffiCaller.rustCallWithError( + /*liftError:*/ FfiConverterTypeFfiError.lift.bind(FfiConverterTypeFfiError), + /*caller:*/ callStatus => { + nativeModule().ubrn_uniffi_rustok_mobile_bindings_fn_func_validate_mnemonic( + FfiConverterString.lower(phrase, nativeModule().rustbuffer_alloc), + callStatus, + ); + }, + /*liftString:*/ FfiConverterString.lift.bind(FfiConverterString), + ); +} + /** * Recommended action (foreign mirror of txguard `Action`). Bind the UI tier to * this enum — not to the numeric risk score. @@ -2120,6 +2154,14 @@ function uniffiEnsureInitialized() { 'uniffi_rustok_mobile_bindings_checksum_func_generate_wallet', ); } + if ( + nativeModule().ubrn_uniffi_rustok_mobile_bindings_checksum_func_validate_mnemonic() !== + 13525 + ) { + throw new UniffiInternalError.ApiChecksumMismatch( + 'uniffi_rustok_mobile_bindings_checksum_func_validate_mnemonic', + ); + } if ( nativeModule().ubrn_uniffi_rustok_mobile_bindings_checksum_constructor_ffiwallet_import_keystore() !== 31223 diff --git a/vendor/core b/vendor/core index bc70f07..13e2330 160000 --- a/vendor/core +++ b/vendor/core @@ -1 +1 @@ -Subproject commit bc70f079eed90014c7421c0714189882dd1dbc8e +Subproject commit 13e2330729d699ca8d7e8d5bfbfdd4bc4d3f30f3 From ab3d2bb67717092664fa473f207430e392eb68e1 Mon Sep 17 00:00:00 2001 From: temrjan Date: Wed, 8 Jul 2026 11:05:15 +0500 Subject: [PATCH 2/7] =?UTF-8?q?feat(onboarding):=20A4=20T3=20=E2=80=94=20p?= =?UTF-8?q?hraseInput=20pure=20helpers=20(split/suggest/member)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure input-shaping for the import screen: splitPastedPhrase mirrors the core normalization (trim/lowercase/whitespace-collapse) for OS paste; suggestWords powers the autocomplete strip (>=2-char prefix, capped, stateless — no per-prefix cache per strip lock 2/NIT-5); isWordlistMember drives the per-cell underline. None gate validity — the Rust FFI is the only judge. 16 tests, red->green proved by mutating the prefix guard and the lowercase step. --- src/lib/__tests__/phraseInput.test.ts | 87 +++++++++++++++++++++++++++ src/lib/phraseInput.ts | 70 +++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/lib/__tests__/phraseInput.test.ts create mode 100644 src/lib/phraseInput.ts diff --git a/src/lib/__tests__/phraseInput.test.ts b/src/lib/__tests__/phraseInput.test.ts new file mode 100644 index 0000000..47478d1 --- /dev/null +++ b/src/lib/__tests__/phraseInput.test.ts @@ -0,0 +1,87 @@ +import { isWordlistMember, splitPastedPhrase, suggestWords } from '../phraseInput'; + +describe('splitPastedPhrase', () => { + it('splits a normal phrase on single spaces', () => { + expect(splitPastedPhrase('abandon ability able')).toEqual(['abandon', 'ability', 'able']); + }); + + it('collapses runs of mixed whitespace (spaces, tabs, newlines)', () => { + expect(splitPastedPhrase('abandon\t\tability \n able')).toEqual([ + 'abandon', + 'ability', + 'able', + ]); + }); + + it('trims leading and trailing whitespace', () => { + expect(splitPastedPhrase(' abandon ability ')).toEqual(['abandon', 'ability']); + }); + + it('lowercases every word (mirrors core normalization)', () => { + expect(splitPastedPhrase('Abandon ABILITY aBLe')).toEqual(['abandon', 'ability', 'able']); + }); + + it('returns [] for blank or whitespace-only input', () => { + expect(splitPastedPhrase('')).toEqual([]); + expect(splitPastedPhrase(' \t\n ')).toEqual([]); + }); + + it('does NOT cap at 12 — an over-length paste is returned whole for the caller to reject', () => { + const twentyFour = Array.from({ length: 24 }, () => 'abandon').join(' '); + expect(splitPastedPhrase(twentyFour)).toHaveLength(24); + }); +}); + +describe('suggestWords', () => { + it('returns wordlist matches for a prefix, in wordlist order', () => { + // "aban" uniquely prefixes "abandon" in BIP-39. + expect(suggestWords('aban')).toEqual(['abandon']); + }); + + it('returns [] for a prefix shorter than 2 chars', () => { + expect(suggestWords('a')).toEqual([]); + expect(suggestWords('')).toEqual([]); + }); + + it('caps results at the default limit of 4', () => { + // "ab" prefixes many words; the strip must not flood. + const result = suggestWords('ab'); + expect(result).toHaveLength(4); + expect(result.every((w) => w.startsWith('ab'))).toBe(true); + }); + + it('honours a custom limit', () => { + expect(suggestWords('ab', 2)).toHaveLength(2); + }); + + it('is case-insensitive on the prefix', () => { + expect(suggestWords('ABAN')).toEqual(['abandon']); + }); + + it('returns [] when nothing matches', () => { + expect(suggestWords('zzzz')).toEqual([]); + }); + + it('recomputes per call — interleaved prefixes never bleed into each other', () => { + // Guards strip lock 2 / NIT-5: no per-prefix cache. If a stale cache existed, + // the second call could echo the first prefix's result. + expect(suggestWords('aban')).toEqual(['abandon']); + expect(suggestWords('zoo')).toEqual(['zoo']); + expect(suggestWords('aban')).toEqual(['abandon']); + }); +}); + +describe('isWordlistMember', () => { + it('is true for a first-of-list and last-of-list word', () => { + expect(isWordlistMember('abandon')).toBe(true); + expect(isWordlistMember('zoo')).toBe(true); + }); + + it('is false for a non-word', () => { + expect(isWordlistMember('zzzz')).toBe(false); + }); + + it('is case-insensitive and trims', () => { + expect(isWordlistMember(' ABANDON ')).toBe(true); + }); +}); diff --git a/src/lib/phraseInput.ts b/src/lib/phraseInput.ts new file mode 100644 index 0000000..e8a9f33 --- /dev/null +++ b/src/lib/phraseInput.ts @@ -0,0 +1,70 @@ +/** + * phraseInput — pure helpers for the A4 import screen's 12-word entry. + * + * None of these validate a mnemonic — the ONLY judge of validity is the Rust + * FFI (`validateMnemonic`), by design (spec: a second source of truth in JS + * risks rejecting a phrase the user could otherwise recover). These functions + * only shape input and offer typing hints: + * - `splitPastedPhrase` mirrors the core normalization (`crypto/mnemonic.rs` + * trim → lowercase → whitespace-collapse) so an OS paste lands word-per-cell; + * - `suggestWords` powers the autocomplete strip — a hint, never a gate; + * - `isWordlistMember` drives the per-cell underline — also a hint. + */ + +import { BIP39_ENGLISH } from './bip39Wordlist'; + +/** O(1) membership over the static wordlist. Built once from the constant list + * — NOT a cache of user input, so it does not violate the strip's no-log rule. */ +const WORDSET: ReadonlySet = new Set(BIP39_ENGLISH); + +/** Shortest prefix that yields autocomplete suggestions (strip lock 2). */ +const MIN_SUGGEST_PREFIX = 2; + +/** + * Splits pasted text into candidate words, mirroring the core's phrase + * normalization (`Mnemonic::from_phrase`): trim, lowercase, split on any + * whitespace run, drop empties. Returns `[]` for blank input. + * + * Intentionally does NOT cap at 12 — the caller decides what to do with an + * over-length paste (the import screen rejects >12 outright rather than + * silently truncating, so a 24-word paste is a visible error, not a lost tail). + */ +export function splitPastedPhrase(text: string): string[] { + return text + .trim() + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); +} + +/** + * Up to `limit` BIP-39 words starting with `prefix` (case-insensitive), in + * wordlist order. Returns `[]` for a prefix shorter than {@link + * MIN_SUGGEST_PREFIX} so a single keystroke does not flood the strip. + * + * Pure and stateless by contract (spec strip lock 2 / NIT-5): no per-prefix + * memoization — a `prefix → result` cache would be an in-memory journal of what + * the user typed. Recomputed on every call. + */ +export function suggestWords(prefix: string, limit = 4): string[] { + const normalized = prefix.trim().toLowerCase(); + if (normalized.length < MIN_SUGGEST_PREFIX) { + return []; + } + const matches: string[] = []; + for (const word of BIP39_ENGLISH) { + if (word.startsWith(normalized)) { + matches.push(word); + if (matches.length === limit) { + break; + } + } + } + return matches; +} + +/** Whether `word` is in the BIP-39 English wordlist (case-insensitive). A + * typing hint only — validity is decided by the Rust FFI, not this. */ +export function isWordlistMember(word: string): boolean { + return WORDSET.has(word.trim().toLowerCase()); +} From 899c7baae9c4691268d2993f68d939f73f3790cf Mon Sep 17 00:00:00 2001 From: temrjan Date: Wed, 8 Jul 2026 11:12:14 +0500 Subject: [PATCH 3/7] =?UTF-8?q?feat(onboarding):=20A4=20T4=20=E2=80=94=20i?= =?UTF-8?q?mport=20branch=20in=20onboardingStore=20+=20FFI=20contract=20mo?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the import path to the step machine: flow='import', beginImport, submitImportPhrase (Rust FFI is the only validity judge — InvalidInput->'invalid', unexpected throw->'error'), and completeImport with an honest failure taxonomy (exists/ffi/storage — NOT the create flow's catch{return 'error'}). confirmPin now branches by flow: create backs up (ShowPhrase/Quiz), import skips straight to commit. Jest bridge mock gains validateMnemonic + the tagged FfiError namespace. 24 store tests (11 new) + 24 deviceWallet green; red->green proved by collapsing the commit taxonomy and the invalid/error discrimination. tsc's navigator exhaustiveness for 'importPhrase' is closed by T5 (screen + navigator case). --- __mocks__/react-native-rustok-bridge.ts | 40 ++++- src/stores/__tests__/onboardingStore.test.ts | 145 ++++++++++++++++++- src/stores/onboardingStore.ts | 104 ++++++++++++- 3 files changed, 278 insertions(+), 11 deletions(-) diff --git a/__mocks__/react-native-rustok-bridge.ts b/__mocks__/react-native-rustok-bridge.ts index bb3890f..5fe6f90 100644 --- a/__mocks__/react-native-rustok-bridge.ts +++ b/__mocks__/react-native-rustok-bridge.ts @@ -44,12 +44,48 @@ function addressFor(mnemonic: string): string { return `0xMOCK${hash.toString(16).padStart(12, '0')}`; } -function ffiError(message: string): Error { - const error = new Error(message); +type FfiErrorTag = 'Wallet' | 'InvalidInput'; + +/** Tagged FfiError, mirroring the generated union's `tag` discriminant so + * consumers can classify errors exactly as they do against the real bridge. */ +function ffiError(message: string, tag: FfiErrorTag = 'Wallet'): Error & { tag: FfiErrorTag } { + const error = new Error(message) as Error & { tag: FfiErrorTag }; error.name = 'FfiError'; + error.tag = tag; return error; } +/** + * Contract mock of the generated `FfiError` tagged-union namespace: only the + * `instanceOf` predicates the app actually uses. Matches on the `tag` field the + * generated union sets (`FfiError_Tags`), so store error-taxonomy tests run + * against the same discriminant as production. + */ +export const FfiError = { + Wallet: { instanceOf: (e: unknown): boolean => (e as { tag?: string })?.tag === 'Wallet' }, + InvalidInput: { + instanceOf: (e: unknown): boolean => (e as { tag?: string })?.tag === 'InvalidInput', + }, +} as const; + +/** + * Models the Rust phrase gate's CONTRACT, not real BIP-39: throws an + * InvalidInput-tagged FfiError for an implausible phrase (not exactly 12 + * non-empty words, or containing the non-word marker `zzzz`), returns void + * otherwise. The genuine checksum validation is the on-device / core-test + * proof; this only lets orchestration tests drive the ok / invalid branches. + */ +export function validateMnemonic(phrase: string): void { + const words = phrase + .trim() + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); + if (words.length !== 12 || words.includes('zzzz')) { + throw ffiError('invalid mnemonic phrase', 'InvalidInput'); + } +} + class MockWallet { constructor( private readonly addr: string, diff --git a/src/stores/__tests__/onboardingStore.test.ts b/src/stores/__tests__/onboardingStore.test.ts index 71319c5..f0e9a17 100644 --- a/src/stores/__tests__/onboardingStore.test.ts +++ b/src/stores/__tests__/onboardingStore.test.ts @@ -6,6 +6,7 @@ */ import * as MMKV from 'react-native-mmkv'; +import * as bridge from 'react-native-rustok-bridge'; import * as deviceWallet from '../../lib/deviceWallet'; import { hasWallet } from '../../lib/deviceWallet'; @@ -17,9 +18,17 @@ import { useWalletStore } from '../walletStore'; const mmkvMock = MMKV as unknown as { __resetMMKV: () => void }; function resetStore(): void { - useOnboardingStore.setState({ step: 'welcome', mnemonic: null, pendingPin: null }); + useOnboardingStore.setState({ step: 'welcome', flow: 'create', mnemonic: null, pendingPin: null }); } +/** 12 real BIP-39 words — the mock accepts any 12 non-empty tokens without a + * `zzzz` marker; the genuine checksum check is the core/on-device proof. */ +const VALID_PHRASE = + 'abandon ability able about above absent absorb abstract absurd abuse access accident'; +/** Contains the `zzzz` non-word marker → the mock throws InvalidInput. */ +const INVALID_PHRASE = + 'zzzz ability able about above absent absorb abstract absurd abuse access accident'; + beforeEach(() => { jest.restoreAllMocks(); mmkvMock.__resetMMKV(); @@ -188,4 +197,138 @@ describe('useOnboardingStore', () => { expect(state.mnemonic).toBeNull(); expect(state.pendingPin).toBeNull(); }); + + it('beginCreate resets flow to create after an abandoned import', () => { + useOnboardingStore.getState().beginImport(); + + useOnboardingStore.getState().beginCreate(); + + expect(useOnboardingStore.getState().flow).toBe('create'); + }); + + // ─── import flow (A4) ─────────────────────────────────────────────────────── + + describe('beginImport', () => { + it('enters the import path at importPhrase with no mnemonic yet', () => { + useOnboardingStore.getState().beginImport(); + + const state = useOnboardingStore.getState(); + expect(state.step).toBe('importPhrase'); + expect(state.flow).toBe('import'); + expect(state.mnemonic).toBeNull(); + }); + }); + + describe('submitImportPhrase', () => { + it('on a valid phrase: stores it and advances to createPin', async () => { + useOnboardingStore.getState().beginImport(); + + const result = await useOnboardingStore.getState().submitImportPhrase(VALID_PHRASE); + + expect(result).toBe('ok'); + const state = useOnboardingStore.getState(); + expect(state.step).toBe('createPin'); + expect(state.mnemonic).toBe(VALID_PHRASE); + }); + + it('on an invalid phrase (FfiError::InvalidInput): returns invalid, stores nothing, stays put', async () => { + useOnboardingStore.getState().beginImport(); + + const result = await useOnboardingStore.getState().submitImportPhrase(INVALID_PHRASE); + + expect(result).toBe('invalid'); + const state = useOnboardingStore.getState(); + expect(state.step).toBe('importPhrase'); // no advance + expect(state.mnemonic).toBeNull(); // a rejected phrase never enters the store + }); + + it('on an unexpected FFI failure (non-InvalidInput throw): returns error, stores nothing', async () => { + // validateMnemonic only ever throws InvalidInput by contract; a different + // throw is an unexpected native failure — distinct from a bad phrase so the + // screen does not tell the user their (valid) phrase is wrong. + jest.spyOn(bridge, 'validateMnemonic').mockImplementationOnce(() => { + throw new Error('native crash'); + }); + useOnboardingStore.getState().beginImport(); + + const result = await useOnboardingStore.getState().submitImportPhrase(VALID_PHRASE); + + expect(result).toBe('error'); + expect(useOnboardingStore.getState().mnemonic).toBeNull(); + }); + }); + + describe('confirmPin (import flow)', () => { + it('on match: persists the PIN but does NOT go to showPhrase — stays for the commit', async () => { + useOnboardingStore.getState().beginImport(); + await useOnboardingStore.getState().submitImportPhrase(VALID_PHRASE); // step → createPin + useOnboardingStore.getState().submitPin('123456'); // step → confirmPin + + const result = await useOnboardingStore.getState().confirmPin('123456'); + + expect(result).toBe('ok'); + const state = useOnboardingStore.getState(); + expect(state.step).toBe('confirmPin'); // NOT showPhrase — import skips backup + expect(state.pendingPin).toBeNull(); + expect(hasPin()).toBe(true); + }); + }); + + describe('completeImport', () => { + it('on success: commits, publishes the session, and wipes the mnemonic', async () => { + useOnboardingStore.setState({ flow: 'import', mnemonic: VALID_PHRASE, step: 'confirmPin' }); + + const result = await useOnboardingStore.getState().completeImport(); + + expect(result).toBe('ok'); + expect(hasWallet()).toBe(true); + expect(useWalletStore.getState().phase).toBe('unlocked'); + const state = useOnboardingStore.getState(); + expect(state.mnemonic).toBeNull(); + expect(state.step).toBe('welcome'); + }); + + it('on a pre-existing wallet: returns exists (single-wallet invariant)', async () => { + const existing = deviceWallet.generateMnemonic(); + await deviceWallet.commitWallet(existing.mnemonic); // a wallet already lives here + useOnboardingStore.setState({ flow: 'import', mnemonic: VALID_PHRASE, step: 'confirmPin' }); + + const result = await useOnboardingStore.getState().completeImport(); + + expect(result).toBe('exists'); + }); + + it('on an FfiError from commit: returns ffi (distinct from storage)', async () => { + jest + .spyOn(deviceWallet, 'commitWallet') + .mockRejectedValueOnce(Object.assign(new Error('import failed'), { name: 'FfiError', tag: 'Wallet' })); + useOnboardingStore.setState({ flow: 'import', mnemonic: VALID_PHRASE, step: 'confirmPin' }); + + const result = await useOnboardingStore.getState().completeImport(); + + expect(result).toBe('ffi'); + expect(useWalletStore.getState().phase).toBe('loading'); // not unlocked + }); + + it('on a keychain/MMKV failure: returns storage (distinct from ffi)', async () => { + jest + .spyOn(deviceWallet, 'commitWallet') + .mockRejectedValueOnce(new Error('mmkv write failed')); + useOnboardingStore.setState({ flow: 'import', mnemonic: VALID_PHRASE, step: 'confirmPin' }); + + const result = await useOnboardingStore.getState().completeImport(); + + expect(result).toBe('storage'); + }); + + it('on a null mnemonic (structurally unreachable, type-forced): returns storage without committing', async () => { + useOnboardingStore.setState({ flow: 'import', mnemonic: null }); + const commitWalletSpy = jest.spyOn(deviceWallet, 'commitWallet'); + + const result = await useOnboardingStore.getState().completeImport(); + + expect(result).toBe('storage'); + expect(commitWalletSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/stores/onboardingStore.ts b/src/stores/onboardingStore.ts index 952e59c..cd8b28e 100644 --- a/src/stores/onboardingStore.ts +++ b/src/stores/onboardingStore.ts @@ -18,8 +18,9 @@ */ import { create } from 'zustand'; +import { FfiError, validateMnemonic } from 'react-native-rustok-bridge'; -import { commitWallet, generateMnemonic } from '../lib/deviceWallet'; +import { commitWallet, generateMnemonic, WalletExistsError } from '../lib/deviceWallet'; import { usePinSetupStore } from './pinSetupStore'; import { useWalletStore } from './walletStore'; @@ -29,11 +30,24 @@ export type OnboardingStep = | 'createPin' | 'confirmPin' | 'showPhrase' - | 'quiz'; + | 'quiz' + | 'importPhrase'; + +/** + * Which onboarding path is running. `create` generates a fresh mnemonic and + * backs it up (ShowPhrase → Quiz); `import` takes a written-down phrase, so it + * skips backup and commits straight after the PIN. The PIN screens are shared. + */ +export type WalletFlow = 'create' | 'import'; + +/** Result of committing an imported phrase — distinct classes get distinct UI. */ +export type ImportCommitResult = 'ok' | 'exists' | 'ffi' | 'storage'; interface OnboardingState { readonly step: OnboardingStep; - /** null until `beginCreate`; never log or persist. */ + /** Which path is running (drives ConfirmPin's post-PIN routing). */ + readonly flow: WalletFlow; + /** null until `beginCreate`/`submitImportPhrase`; never log or persist. */ readonly mnemonic: string | null; /** The PIN entered on CreatePin, awaiting the ConfirmPin re-entry. */ readonly pendingPin: string | null; @@ -42,6 +56,24 @@ interface OnboardingState { start: () => void; /** Generates the mnemonic (in memory only) and advances to KeepItSafe. */ beginCreate: () => void; + /** Enters the import path (no mnemonic yet) at the ImportPhrase screen. */ + beginImport: () => void; + /** + * Validates a typed/pasted phrase through the Rust FFI — the ONLY judge of + * validity. On `ok`, stores the phrase and advances to CreatePin (PIN is set + * before commit, same as create). `invalid` = a bad BIP-39 phrase + * (discriminable `FfiError::InvalidInput`); `error` = an unexpected FFI + * failure. The phrase is never logged/persisted (mnemonic-3 invariant). + */ + submitImportPhrase: (phrase: string) => Promise<'ok' | 'invalid' | 'error'>; + /** + * Commits the already-validated imported phrase after the PIN is confirmed + * (import flow only). Returns a distinct class per failure so the screen can + * message honestly — NOT the create flow's `catch { return 'error' }`: + * `exists` (a wallet already exists), `ffi` (import/derive threw), `storage` + * (keychain/MMKV threw). On `ok`, publishes the session to `walletStore`. + */ + completeImport: () => Promise; /** Advances past the 3 KeepItSafe acknowledgements to CreatePin. */ acceptSafety: () => void; /** Records the first PIN entry and advances to ConfirmPin. */ @@ -71,15 +103,38 @@ interface OnboardingState { export const useOnboardingStore = create((set, get) => ({ step: 'welcome', + flow: 'create', mnemonic: null, pendingPin: null, start: () => { - set({ step: 'welcome', mnemonic: null, pendingPin: null }); + set({ step: 'welcome', flow: 'create', mnemonic: null, pendingPin: null }); }, beginCreate: () => { const { mnemonic } = generateMnemonic(); - set({ step: 'keepItSafe', mnemonic }); + set({ step: 'keepItSafe', flow: 'create', mnemonic }); + }, + beginImport: () => { + // No mnemonic yet — the user types it on ImportPhrase; it enters the store + // only after the Rust FFI validates it (`submitImportPhrase`). + set({ step: 'importPhrase', flow: 'import', mnemonic: null, pendingPin: null }); + }, + submitImportPhrase: async (phrase: string) => { + try { + // The Rust FFI is the ONLY validity judge (no JS checksum, by spec). + // Synchronous throw on a bad phrase; a valid one returns void. + validateMnemonic(phrase); + } catch (error: unknown) { + if (FfiError.InvalidInput.instanceOf(error)) { + return 'invalid'; + } + // Any non-InvalidInput throw is an unexpected FFI/native failure. + return 'error'; + } + // PIN is set before the commit (same ordering as create); the phrase lives + // in memory only until `completeImport` commits it. + set({ step: 'createPin', mnemonic: phrase }); + return 'ok'; }, acceptSafety: () => { set({ step: 'createPin' }); @@ -88,13 +143,21 @@ export const useOnboardingStore = create((set, get) => ({ set({ step: 'confirmPin', pendingPin: pin }); }, confirmPin: async (pin: string) => { - const { pendingPin } = get(); + const { pendingPin, flow } = get(); if (pin !== pendingPin) { set({ step: 'createPin', pendingPin: null }); return 'mismatch'; } await usePinSetupStore.getState().setPin(pin); - set({ step: 'showPhrase', pendingPin: null }); + // create → back up the phrase next (ShowPhrase → Quiz commits). import → the + // phrase is already known and validated, so ConfirmPin drives + // `completeImport` next; stay on this step (no ShowPhrase/Quiz for a phrase + // the user just typed from their own backup). + if (flow === 'create') { + set({ step: 'showPhrase', pendingPin: null }); + } else { + set({ pendingPin: null }); + } return 'ok'; }, continueToQuiz: () => { @@ -117,7 +180,32 @@ export const useOnboardingStore = create((set, get) => ({ return 'error'; } }, + completeImport: async () => { + const { mnemonic } = get(); + // Structurally unreachable: `completeImport` only runs after + // `submitImportPhrase('ok')` set the mnemonic and `confirmPin('ok')`. The + // nullable type forces the branch; a null here is a programming error, so + // fold it into the generic-failure class (its message never shows). + if (mnemonic === null) { + return 'storage'; + } + try { + const session = await commitWallet(mnemonic); + useWalletStore.getState().setUnlocked(session); + get().start(); + return 'ok'; + } catch (error: unknown) { + // Honest taxonomy (spec): three failure classes, three messages. + if (error instanceof WalletExistsError) { + return 'exists'; + } + if (FfiError.Wallet.instanceOf(error) || FfiError.InvalidInput.instanceOf(error)) { + return 'ffi'; + } + return 'storage'; + } + }, cancel: () => { - set({ step: 'welcome', mnemonic: null, pendingPin: null }); + set({ step: 'welcome', flow: 'create', mnemonic: null, pendingPin: null }); }, })); From 692cf99de9f4501c018c7fd8670f4e3dd37d9c1b Mon Sep 17 00:00:00 2001 From: temrjan Date: Wed, 8 Jul 2026 11:22:27 +0500 Subject: [PATCH 4/7] =?UTF-8?q?feat(onboarding):=20A4=20T5=20=E2=80=94=20I?= =?UTF-8?q?mportPhrase=20screen=20+=20ConfirmPin=20commit=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImportPhrase: 12-field grid with anti-leak TextInput props (visible-password suppresses Gboard suggestions), OS-paste layout with a whole-reject on >12 words (no silent truncation), a local-wordlist autocomplete strip (explicit-tap only, no cache), and a membership underline hint — none of which gate validity (the Rust FFI does). ConfirmPin becomes flow-aware: create is unchanged; import commits the already-validated phrase after the PIN, with a spinner and an honest per-class error + Retry. Navigator routes the new importPhrase step. 9 ImportPhrase tests + 2 navigator import-path tests (happy + commit-fail/retry). --- src/navigation/OnboardingNavigator.tsx | 3 + .../__tests__/OnboardingNavigator.test.tsx | 84 ++++++- src/screens/onboarding/ConfirmPin.tsx | 101 +++++++- src/screens/onboarding/ImportPhrase.tsx | 235 ++++++++++++++++++ .../__tests__/ImportPhrase.test.tsx | 162 ++++++++++++ 5 files changed, 572 insertions(+), 13 deletions(-) create mode 100644 src/screens/onboarding/ImportPhrase.tsx create mode 100644 src/screens/onboarding/__tests__/ImportPhrase.test.tsx diff --git a/src/navigation/OnboardingNavigator.tsx b/src/navigation/OnboardingNavigator.tsx index 33745f1..31f99b9 100644 --- a/src/navigation/OnboardingNavigator.tsx +++ b/src/navigation/OnboardingNavigator.tsx @@ -1,6 +1,7 @@ import { assertNever } from '../lib/assertNever'; import { ConfirmPin } from '../screens/onboarding/ConfirmPin'; import { CreatePin } from '../screens/onboarding/CreatePin'; +import { ImportPhrase } from '../screens/onboarding/ImportPhrase'; import { KeepItSafe } from '../screens/onboarding/KeepItSafe'; import { Quiz } from '../screens/onboarding/Quiz'; import { ShowPhrase } from '../screens/onboarding/ShowPhrase'; @@ -19,6 +20,8 @@ export function OnboardingNavigator() { switch (step) { case 'welcome': return ; + case 'importPhrase': + return ; case 'keepItSafe': return ; case 'createPin': diff --git a/src/navigation/__tests__/OnboardingNavigator.test.tsx b/src/navigation/__tests__/OnboardingNavigator.test.tsx index 12be874..c8db4b7 100644 --- a/src/navigation/__tests__/OnboardingNavigator.test.tsx +++ b/src/navigation/__tests__/OnboardingNavigator.test.tsx @@ -12,6 +12,7 @@ import * as Keychain from 'react-native-keychain'; import * as MMKV from 'react-native-mmkv'; import TestRenderer, { act } from 'react-test-renderer'; +import * as deviceWallet from '../../lib/deviceWallet'; import { hasWallet } from '../../lib/deviceWallet'; import { hasPin } from '../../lib/pinAuth'; import { useOnboardingStore } from '../../stores/onboardingStore'; @@ -58,8 +59,9 @@ beforeEach(() => { mmkvMock.__resetMMKV(); keychainMock.__resetKeychain(); bridgeMock.__resetBridge(); + jest.restoreAllMocks(); act(() => { - useOnboardingStore.setState({ step: 'welcome', mnemonic: null, pendingPin: null }); + useOnboardingStore.setState({ step: 'welcome', flow: 'create', mnemonic: null, pendingPin: null }); useWalletStore.setState({ phase: 'loading', address: null, session: null }); }); }); @@ -159,3 +161,83 @@ describe('OnboardingNavigator — Create happy path', () => { }); }); }); + +describe('OnboardingNavigator — Import happy path', () => { + const VALID_PHRASE = + 'abandon ability able about above absent absorb abstract absurd abuse access accident'; + + /** Welcome(import) → ImportPhrase(paste + submit) → CreatePin, ready for the PIN. */ + async function reachImportConfirmPin(tree: TestRenderer.ReactTestRenderer): Promise { + tap(tree, 'welcome-import'); + expect(useOnboardingStore.getState().step).toBe('importPhrase'); + act(() => { + tree.root.findByProps({ testID: 'import-word-0' }).props.onChangeText(VALID_PHRASE); + }); + await act(async () => { + await tree.root.findByProps({ testID: 'import-submit' }).props.onPress(); + }); + expect(useOnboardingStore.getState().step).toBe('createPin'); + pressPin(tree, '123456'); // → ConfirmPin + } + + it('walks Welcome→ImportPhrase→CreatePin→ConfirmPin to unlocked — no ShowPhrase/Quiz, wallet committed after the PIN', async () => { + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + + await reachImportConfirmPin(tree!); + expect(hasWallet()).toBe(false); // not committed until the PIN is confirmed + + pressPin(tree!, '123456'); // confirm → completeImport + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + + expect(hasWallet()).toBe(true); + expect(hasPin()).toBe(true); + expect(useWalletStore.getState().phase).toBe('unlocked'); + expect(useOnboardingStore.getState().mnemonic).toBeNull(); // wiped after commit + + act(() => { + tree.unmount(); + }); + }); + + it('on a commit failure: shows an honest error with a Retry that commits cleanly', async () => { + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + await reachImportConfirmPin(tree!); + + const commitSpy = jest + .spyOn(deviceWallet, 'commitWallet') + .mockRejectedValueOnce(new Error('mmkv write failed')); + + pressPin(tree!, '123456'); // confirm → completeImport → 'storage' + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + + expect(tree!.root.findByProps({ testID: 'confirm-import-error' }).props.children).toBe( + 'Could not save the wallet. Please try again.', + ); + expect(useWalletStore.getState().phase).toBe('loading'); // not unlocked + expect(commitSpy).toHaveBeenCalledTimes(1); + + // Retry re-commits — the spy's one-shot rejection is spent, so the real + // commit runs and the wallet unlocks. + await act(async () => { + await tree!.root.findByProps({ testID: 'confirm-import-retry' }).props.onPress(); + await new Promise((resolve) => setImmediate(resolve)); + }); + + expect(hasWallet()).toBe(true); + expect(useWalletStore.getState().phase).toBe('unlocked'); + + act(() => { + tree.unmount(); + }); + }); +}); diff --git a/src/screens/onboarding/ConfirmPin.tsx b/src/screens/onboarding/ConfirmPin.tsx index 773083e..f1b8a00 100644 --- a/src/screens/onboarding/ConfirmPin.tsx +++ b/src/screens/onboarding/ConfirmPin.tsx @@ -1,26 +1,103 @@ /** - * ConfirmPin — PIN re-entry. `onboardingStore.confirmPin` already routes the - * step back to `createPin` on a mismatch, so a fresh `PinKeypad` there is the - * reset — no local error/shake state needed here (a fuller shake/haptics - * choreography lands with the Quiz wrong-answer case in A2b, per the spec's - * open question, so it isn't built twice). + * ConfirmPin — PIN re-entry, shared by both flows. + * + * create: `onboardingStore.confirmPin` routes the step (createPin on mismatch, + * showPhrase on match) — a fresh `PinKeypad` on mismatch is the reset, no local + * error state needed (the fuller shake/haptics live in the Quiz case). + * + * import: the phrase is already validated, so a matching PIN commits it right + * away via `completeImport` — this screen shows the commit spinner and, on + * failure, an honest per-class message with a Retry (the PIN is already + * persisted and the phrase is still in the store, so Retry re-commits cleanly). */ -import { Text, View } from 'react-native'; +import { useState } from 'react'; +import { ActivityIndicator, Pressable, Text, View } from 'react-native'; import { PinKeypad } from '../../components/PinKeypad'; -import { useOnboardingStore } from '../../stores/onboardingStore'; +import { useOnboardingStore, type ImportCommitResult } from '../../stores/onboardingStore'; +import { themes } from '../../theme/theme'; const PIN_LENGTH = 6; +// ActivityIndicator takes a color prop, not a className (SplashScreen pattern). +const SPINNER_COLOR = themes.dark.accent; + +function importErrorMessage(result: Exclude): string { + switch (result) { + case 'exists': + return 'A wallet already exists on this device.'; + case 'ffi': + return 'Could not import the wallet. Please try again.'; + case 'storage': + return 'Could not save the wallet. Please try again.'; + } +} export function ConfirmPin() { + const flow = useOnboardingStore((state) => state.flow); const confirmPin = useOnboardingStore((state) => state.confirmPin); + const completeImport = useOnboardingStore((state) => state.completeImport); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function runCommit(): Promise { + setBusy(true); + setError(null); + try { + const result = await completeImport(); + // 'ok' → walletStore flips to unlocked, RootNavigator swaps onboarding out. + if (result !== 'ok') { + setError(importErrorMessage(result)); + } + } catch { + setError('Something went wrong. Please try again.'); + } finally { + setBusy(false); + } + } + + function handleComplete(pin: string): void { + setError(null); + // A remaining crypto/storage throw from `setPin` has no error UI in the + // create flow (unchanged); import commit errors are handled in `runCommit`. + confirmPin(pin) + .then((result) => { + // create: the store already routed (showPhrase / createPin). import + ok: + // the PIN is set, so commit the already-validated phrase now. + if (result === 'ok' && flow === 'import') { + return runCommit(); + } + return undefined; + }) + .catch(() => undefined); + } + + if (busy) { + return ( + + + + ); + } - function handleComplete(pin: string) { - // PinKeypad always yields exactly 6 digits, so `InvalidPinError` cannot - // fire here; a remaining crypto/storage throw from `pinAuth.setPin` has - // no error UI yet in this slice (no-floating-promises requires a catch). - confirmPin(pin).catch(() => undefined); + if (error !== null) { + return ( + + + {error} + + { + runCommit().catch(() => undefined); + }} + className="h-[54px] w-full items-center justify-center rounded-control bg-accent" + > + Retry + + + ); } return ( diff --git a/src/screens/onboarding/ImportPhrase.tsx b/src/screens/onboarding/ImportPhrase.tsx new file mode 100644 index 0000000..50ec3c1 --- /dev/null +++ b/src/screens/onboarding/ImportPhrase.tsx @@ -0,0 +1,235 @@ +/** + * ImportPhrase — A4 recovery-phrase entry: 12 words → Rust validation → PIN → + * commit. Activates the Welcome "import" path (`beginImport`). No flipbook frame + * exists for it (carry-over #7 precedent) — built from the same tokens. + * + * Security posture (the app's most sensitive surface — a phrase is typed here): + * - anti-leak `TextInput` props keep the keyboard from learning or autofilling + * the phrase (`visible-password` also suppresses Gboard's suggestion strip — + * verified on-device, smoke #6); + * - words are shown (not `secureTextEntry`) so the user can check them against + * the written backup; + * - the raw 12 fields live ONLY in this component's state and vanish on unmount + * (hardware-back → `cancel()`); nothing is logged or persisted, and the store + * never sees the phrase until the FFI validates it (mnemonic-3 invariant); + * - validity is decided ONLY by the Rust FFI — the wordlist drives the + * underline hint and the autocomplete strip, never a gate (a JS checksum + * could reject a phrase the user could otherwise recover). + * + * Autocomplete strip locks (spec): (1) source is the local wordlist only; (2) no + * logging/caching of what was typed — `suggestWords` is pure; (3) only an + * explicit tap fills a word, never an auto-accept on blur/submit (a silent word + * swap = a different valid phrase = someone else's wallet). + */ + +import { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, BackHandler, Pressable, ScrollView, Text, TextInput, View } from 'react-native'; + +import { isWordlistMember, splitPastedPhrase, suggestWords } from '../../lib/phraseInput'; +import { useOnboardingStore } from '../../stores/onboardingStore'; +import { themes } from '../../theme/theme'; + +const WORD_COUNT = 12; +const EMPTY_WORDS: readonly string[] = Array.from({ length: WORD_COUNT }, () => ''); +const SPINNER_COLOR = themes.dark.accent; + +const OVER_LENGTH_MESSAGE = 'Only 12-word phrases are supported'; +const INVALID_MESSAGE = 'This is not a valid recovery phrase'; +const UNEXPECTED_MESSAGE = 'Something went wrong. Please try again.'; + +// Anti-leak entry: stop the keyboard learning/autofilling the phrase. +// `visible-password` suppresses the Android suggestion strip (smoke #6). +const SECURE_ENTRY_PROPS = { + autoCapitalize: 'none', + autoComplete: 'off', + autoCorrect: false, + spellCheck: false, + importantForAutofill: 'no', + textContentType: 'none', + keyboardType: 'visible-password', +} as const; + +export function ImportPhrase() { + const submitImportPhrase = useOnboardingStore((state) => state.submitImportPhrase); + const cancel = useOnboardingStore((state) => state.cancel); + + const [words, setWords] = useState(EMPTY_WORDS); + const [focused, setFocused] = useState(0); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const inputs = useRef>([]); + + useEffect(() => { + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + cancel(); + return true; + }); + return () => sub.remove(); + }, [cancel]); + + function setWord(index: number, value: string): void { + setWords((prev) => { + const next = [...prev]; + next[index] = value; + return next; + }); + } + + function focusField(index: number): void { + if (index < WORD_COUNT) { + inputs.current[index]?.focus(); + } + } + + function handleChangeText(index: number, text: string): void { + if (error !== null) { + setError(null); + } + const endsWithWhitespace = /\s$/.test(text); + const tokens = splitPastedPhrase(text); + + // One token, still typing → just record it. + if (tokens.length <= 1 && !endsWithWhitespace) { + setWord(index, tokens[0] ?? ''); + return; + } + // One token + a trailing space → commit the word and advance (muscle memory + // "space = next word"), distinct from a multi-word paste (МИНОР-2). + if (tokens.length <= 1) { + setWord(index, tokens[0] ?? ''); + focusField(index + 1); + return; + } + // Multi-word paste → lay out from the current field. Reject an over-length + // paste WHOLE — no silent truncation (a 24-word paste is a visible error, + // not a lost tail); no field changes. + if (index + tokens.length > WORD_COUNT) { + setError(OVER_LENGTH_MESSAGE); + return; + } + setWords((prev) => { + const next = [...prev]; + tokens.forEach((token, k) => { + next[index + k] = token; + }); + return next; + }); + focusField(index + tokens.length - 1); + } + + function acceptSuggestion(word: string): void { + // Strip lock 3: fill ONLY on an explicit tap, then advance. Never auto-accept. + setWord(focused, word); + focusField(focused + 1); + } + + const filled = words.every((word) => word.trim().length > 0); + const suggestions = suggestWords(words[focused] ?? ''); + + async function handleImport(): Promise { + if (!filled || busy) { + return; + } + setBusy(true); + setError(null); + try { + const result = await submitImportPhrase(words.join(' ')); + // 'ok' → store advances to createPin; OnboardingNavigator swaps this out. + if (result === 'invalid') { + setError(INVALID_MESSAGE); + } else if (result === 'error') { + setError(UNEXPECTED_MESSAGE); + } + } finally { + setBusy(false); + } + } + + return ( + + + + Enter your recovery phrase + + + Type or paste your 12-word phrase. It never leaves this device. + + + {words.map((word, i) => { + const flagged = word.trim().length > 0 && !isWordlistMember(word); + return ( + + + {String(i + 1).padStart(2, '0')} + + { + inputs.current[i] = el; + }} + testID={`import-word-${i}`} + value={word} + onChangeText={(text) => handleChangeText(i, text)} + onFocus={() => setFocused(i)} + className="flex-1 font-mono text-sm text-textPrimary" + {...SECURE_ENTRY_PROPS} + /> + + ); + })} + + {suggestions.length > 0 && ( + + {suggestions.map((word) => ( + acceptSuggestion(word)} + className="rounded-field border border-borderDefault bg-surface px-3 py-1.5" + > + {word} + + ))} + + )} + {error !== null && ( + + {error} + + )} + + + + {busy ? ( + + ) : ( + + Import + + )} + + + + ); +} diff --git a/src/screens/onboarding/__tests__/ImportPhrase.test.tsx b/src/screens/onboarding/__tests__/ImportPhrase.test.tsx new file mode 100644 index 0000000..479aacb --- /dev/null +++ b/src/screens/onboarding/__tests__/ImportPhrase.test.tsx @@ -0,0 +1,162 @@ +import TestRenderer, { act } from 'react-test-renderer'; + +import { useOnboardingStore } from '../../../stores/onboardingStore'; +import { ImportPhrase } from '../ImportPhrase'; + +const VALID_PHRASE = + 'abandon ability able about above absent absorb abstract absurd abuse access accident'; + +let tree: TestRenderer.ReactTestRenderer | undefined; + +function field(index: number) { + return tree!.root.findByProps({ testID: `import-word-${index}` }); +} + +function typeInto(index: number, text: string): void { + act(() => { + field(index).props.onFocus(); + field(index).props.onChangeText(text); + }); +} + +beforeEach(() => { + jest.restoreAllMocks(); + useOnboardingStore.setState({ + step: 'importPhrase', + flow: 'import', + mnemonic: null, + pendingPin: null, + }); +}); + +afterEach(() => { + act(() => { + tree?.unmount(); + }); + tree = undefined; +}); + +describe('ImportPhrase', () => { + it('renders 12 word fields and a submit button that starts disabled', () => { + act(() => { + tree = TestRenderer.create(); + }); + + for (let i = 0; i < 12; i += 1) { + expect(field(i)).toBeDefined(); + } + expect(tree!.root.findByProps({ testID: 'import-submit' }).props.disabled).toBe(true); + }); + + it('records a typed word (lowercased) without auto-filling from the strip', () => { + act(() => { + tree = TestRenderer.create(); + }); + + typeInto(0, 'Aban'); + + // Strip lock 3: typing NEVER auto-accepts a suggestion — the field holds + // exactly what was typed (lowercased) until an explicit chip tap. + expect(field(0).props.value).toBe('aban'); + }); + + it('a trailing space commits the word to the current field (not a paste layout)', () => { + act(() => { + tree = TestRenderer.create(); + }); + + typeInto(0, 'abandon '); + + expect(field(0).props.value).toBe('abandon'); + expect(field(1).props.value).toBe(''); // did NOT spill into the next field + }); + + it('a multi-word paste lays the words out across fields from the current one', () => { + act(() => { + tree = TestRenderer.create(); + }); + + typeInto(0, VALID_PHRASE); + + const words = VALID_PHRASE.split(' '); + for (const [i, word] of words.entries()) { + expect(field(i).props.value).toBe(word); + } + }); + + it('rejects an over-length paste WHOLE (no truncation) and shows an error', () => { + act(() => { + tree = TestRenderer.create(); + }); + const twentyFour = Array.from({ length: 24 }, () => 'abandon').join(' '); + + typeInto(0, twentyFour); + + expect(tree!.root.findByProps({ testID: 'import-error' }).props.children).toBe( + 'Only 12-word phrases are supported', + ); + // Not one field was mutated by the rejected paste. + for (let i = 0; i < 12; i += 1) { + expect(field(i).props.value).toBe(''); + } + }); + + it('offers wordlist suggestions and fills a field only on an explicit tap', () => { + act(() => { + tree = TestRenderer.create(); + }); + + typeInto(0, 'aban'); + const chip = tree!.root.findByProps({ testID: 'import-suggest-abandon' }); + expect(chip).toBeDefined(); + expect(field(0).props.value).toBe('aban'); // not yet filled + + act(() => { + chip.props.onPress(); + }); + + expect(field(0).props.value).toBe('abandon'); // filled by the tap + }); + + it('flags a non-wordlist word with the warning border (a hint, not a gate)', () => { + act(() => { + tree = TestRenderer.create(); + }); + + typeInto(0, 'zzzz'); + + const wrapper = field(0).parent!; + expect(wrapper.props.className).toContain('border-riskWarnBorder'); + }); + + it('on a valid phrase: submit advances the flow to createPin', async () => { + act(() => { + tree = TestRenderer.create(); + }); + typeInto(0, VALID_PHRASE); + + await act(async () => { + await tree!.root.findByProps({ testID: 'import-submit' }).props.onPress(); + }); + + expect(useOnboardingStore.getState().step).toBe('createPin'); + expect(useOnboardingStore.getState().mnemonic).toBe(VALID_PHRASE); + }); + + it('on an invalid phrase: submit shows the invalid message and does not advance', async () => { + act(() => { + tree = TestRenderer.create(); + }); + // 12 tokens with the mock's non-word marker → FfiError::InvalidInput. + typeInto(0, 'zzzz ability able about above absent absorb abstract absurd abuse access accident'); + + await act(async () => { + await tree!.root.findByProps({ testID: 'import-submit' }).props.onPress(); + }); + + expect(tree!.root.findByProps({ testID: 'import-error' }).props.children).toBe( + 'This is not a valid recovery phrase', + ); + expect(useOnboardingStore.getState().step).toBe('importPhrase'); + }); +}); From 466223db16e093a875236ae2f0ad01b4f6916fc4 Mon Sep 17 00:00:00 2001 From: temrjan Date: Wed, 8 Jul 2026 11:22:27 +0500 Subject: [PATCH 5/7] =?UTF-8?q?feat(onboarding):=20A4=20T6=20=E2=80=94=20a?= =?UTF-8?q?ctivate=20Welcome=20import=20button=20+=20pin=20the=20wordlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Welcome's 'import' button now enters the import flow (beginImport) instead of sitting inert. Adds a corruption pin over BIP39_ENGLISH (2048 words, abandon…zoo, FNV-1a fingerprint) so a silently dropped/reordered word fails CI rather than quietly degrading the strip/underline hints. red->green proved by mutating one word. --- src/lib/__tests__/bip39Wordlist.test.ts | 34 +++++++++++++++++++++++++ src/screens/onboarding/Welcome.tsx | 4 +-- 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/bip39Wordlist.test.ts diff --git a/src/lib/__tests__/bip39Wordlist.test.ts b/src/lib/__tests__/bip39Wordlist.test.ts new file mode 100644 index 0000000..de43a7f --- /dev/null +++ b/src/lib/__tests__/bip39Wordlist.test.ts @@ -0,0 +1,34 @@ +import { BIP39_ENGLISH } from '../bip39Wordlist'; + +/** + * Pins the BIP-39 English wordlist against silent corruption. The list is not a + * validity gate (the Rust FFI is — a divergence between this list and + * `coins_bip39::English` is a benign hint mismatch, not a lost wallet), but a + * dropped/reordered/mistyped word would quietly break the autocomplete strip + * and the underline hint. This does NOT assert equality with the Rust list — + * only that OUR copy is unchanged from the reviewed 2048-word snapshot. + * + * A pure-JS FNV-1a fingerprint is used rather than SHA-256: the RN typecheck + * config has no Node type declarations, so `import 'crypto'` fails `tsc`. FNV-1a + * detects any single-word change just as well for a corruption pin. + */ +function fnv1a(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +describe('BIP39_ENGLISH', () => { + it('has exactly 2048 words, abandon…zoo', () => { + expect(BIP39_ENGLISH).toHaveLength(2048); + expect(BIP39_ENGLISH[0]).toBe('abandon'); + expect(BIP39_ENGLISH[BIP39_ENGLISH.length - 1]).toBe('zoo'); + }); + + it('matches the pinned fingerprint of the reviewed snapshot', () => { + expect(fnv1a(BIP39_ENGLISH.join('\n'))).toBe('b52f0582'); + }); +}); diff --git a/src/screens/onboarding/Welcome.tsx b/src/screens/onboarding/Welcome.tsx index 012658a..e066917 100644 --- a/src/screens/onboarding/Welcome.tsx +++ b/src/screens/onboarding/Welcome.tsx @@ -15,6 +15,7 @@ import { useOnboardingStore } from '../../stores/onboardingStore'; export function Welcome() { const beginCreate = useOnboardingStore((state) => state.beginCreate); + const beginImport = useOnboardingStore((state) => state.beginImport); return ( @@ -37,10 +38,9 @@ export function Welcome() { Создать кошелёк - {/* A4 — import flow. Visually present, inert until then. */} From 8304393c5e61a0e668d22818039d7b407c592fa7 Mon Sep 17 00:00:00 2001 From: temrjan Date: Wed, 8 Jul 2026 11:26:17 +0500 Subject: [PATCH 6/7] =?UTF-8?q?test(onboarding):=20A4=20=E2=80=94=20Confir?= =?UTF-8?q?mPin=20import=20error-taxonomy=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the three distinct commit-failure messages (exists/ffi/storage) by driving the real completeImport with a per-class commitWallet rejection. Lifts ConfirmPin branch coverage 76.9%->92.3%. --- src/lib/__tests__/bip39Wordlist.test.ts | 2 + .../onboarding/__tests__/ConfirmPin.test.tsx | 105 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/screens/onboarding/__tests__/ConfirmPin.test.tsx diff --git a/src/lib/__tests__/bip39Wordlist.test.ts b/src/lib/__tests__/bip39Wordlist.test.ts index de43a7f..b3969ee 100644 --- a/src/lib/__tests__/bip39Wordlist.test.ts +++ b/src/lib/__tests__/bip39Wordlist.test.ts @@ -15,9 +15,11 @@ import { BIP39_ENGLISH } from '../bip39Wordlist'; function fnv1a(text: string): string { let hash = 0x811c9dc5; for (let i = 0; i < text.length; i += 1) { + // eslint-disable-next-line no-bitwise -- FNV-1a mixes with xor by definition hash ^= text.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } + // eslint-disable-next-line no-bitwise -- coerce to an unsigned 32-bit result return (hash >>> 0).toString(16).padStart(8, '0'); } diff --git a/src/screens/onboarding/__tests__/ConfirmPin.test.tsx b/src/screens/onboarding/__tests__/ConfirmPin.test.tsx new file mode 100644 index 0000000..a38277b --- /dev/null +++ b/src/screens/onboarding/__tests__/ConfirmPin.test.tsx @@ -0,0 +1,105 @@ +/** + * ConfirmPin — focused import-flow tests for the commit error taxonomy. The + * create flow and the import happy/retry paths are covered by the + * OnboardingNavigator integration test; this pins the three distinct failure + * messages (spec requirement) by driving the real `completeImport` with a + * spied `commitWallet` rejection per class. + */ + +import * as Keychain from 'react-native-keychain'; +import * as MMKV from 'react-native-mmkv'; +import TestRenderer, { act } from 'react-test-renderer'; + +import * as deviceWallet from '../../../lib/deviceWallet'; +import { useOnboardingStore } from '../../../stores/onboardingStore'; +import { useWalletStore } from '../../../stores/walletStore'; +import { ConfirmPin } from '../ConfirmPin'; + +const mmkvMock = MMKV as unknown as { __resetMMKV: () => void }; +const keychainMock = Keychain as unknown as { __resetKeychain: () => void }; +const bridgeMock = jest.requireMock('react-native-rustok-bridge') as { __resetBridge: () => void }; + +const PHRASE = 'abandon ability able about above absent absorb abstract absurd abuse access accident'; + +let tree: TestRenderer.ReactTestRenderer | undefined; + +function pressPin(pin: string): void { + for (const digit of pin) { + act(() => { + tree!.root.findByProps({ testID: `pin-key-${digit}` }).props.onPress(); + }); + } +} + +async function confirmAndFlush(): Promise { + pressPin('123456'); + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); +} + +function errorText(): unknown { + return tree!.root.findByProps({ testID: 'confirm-import-error' }).props.children; +} + +beforeEach(() => { + jest.restoreAllMocks(); + mmkvMock.__resetMMKV(); + keychainMock.__resetKeychain(); + bridgeMock.__resetBridge(); + act(() => { + useOnboardingStore.setState({ + step: 'confirmPin', + flow: 'import', + mnemonic: PHRASE, + pendingPin: '123456', + }); + useWalletStore.setState({ phase: 'loading', address: null, session: null }); + }); +}); + +afterEach(() => { + act(() => { + tree?.unmount(); + }); + tree = undefined; +}); + +describe('ConfirmPin — import commit error taxonomy', () => { + it('a pre-existing wallet → the exists message', async () => { + jest + .spyOn(deviceWallet, 'commitWallet') + .mockRejectedValueOnce(new deviceWallet.WalletExistsError()); + act(() => { + tree = TestRenderer.create(); + }); + + await confirmAndFlush(); + + expect(errorText()).toBe('A wallet already exists on this device.'); + }); + + it('an FfiError from commit → the import-failed message (distinct from storage)', async () => { + jest + .spyOn(deviceWallet, 'commitWallet') + .mockRejectedValueOnce(Object.assign(new Error('import failed'), { name: 'FfiError', tag: 'Wallet' })); + act(() => { + tree = TestRenderer.create(); + }); + + await confirmAndFlush(); + + expect(errorText()).toBe('Could not import the wallet. Please try again.'); + }); + + it('a keychain/MMKV failure → the save-failed message (distinct from ffi)', async () => { + jest.spyOn(deviceWallet, 'commitWallet').mockRejectedValueOnce(new Error('mmkv write failed')); + act(() => { + tree = TestRenderer.create(); + }); + + await confirmAndFlush(); + + expect(errorText()).toBe('Could not save the wallet. Please try again.'); + }); +}); From feebd258bbd2cf20a6945166deda35e87d1a9f73 Mon Sep 17 00:00:00 2001 From: temrjan Date: Wed, 8 Jul 2026 11:55:32 +0500 Subject: [PATCH 7/7] =?UTF-8?q?fix(onboarding):=20A4=20Gate-2=20MINORs=20?= =?UTF-8?q?=E2=80=94=20mock=20agnostic,=20shared=20spinner,=20back+overflo?= =?UTF-8?q?w=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1: test hardware-back → cancel() on ImportPhrase (raw fields never survive). M2: pin the index+tokens overflow guard with a paste from a non-zero field (an index-blind regress would otherwise pass green). M3: mock validateMnemonic accepts all valid BIP-39 lengths (12/15/18/21/24) — stop baking the UI's 12-only rule into the FFI contract (real FFI is agnostic). M4: SPINNER_COLOR exported from theme.ts, used by ConfirmPin + ImportPhrase. N5: importErrorMessage gets an assertNever default (repo exhaustiveness idiom). N6: assert cancel() wipes a validated import mnemonic + resets flow. jest 196/196; M2 red->green proved by mutating the guard to index-blind. --- __mocks__/react-native-rustok-bridge.ts | 15 ++++-- src/screens/onboarding/ConfirmPin.tsx | 7 +-- src/screens/onboarding/ImportPhrase.tsx | 3 +- .../__tests__/ImportPhrase.test.tsx | 50 +++++++++++++++++++ src/stores/__tests__/onboardingStore.test.ts | 12 +++++ src/theme/theme.ts | 7 +++ 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/__mocks__/react-native-rustok-bridge.ts b/__mocks__/react-native-rustok-bridge.ts index 5fe6f90..baa76a4 100644 --- a/__mocks__/react-native-rustok-bridge.ts +++ b/__mocks__/react-native-rustok-bridge.ts @@ -68,12 +68,17 @@ export const FfiError = { }, } as const; +/** Valid BIP-39 mnemonic lengths — the real FFI is word-count-agnostic (core + * `validate_mnemonic`, ratified), so the mock must NOT bake the UI's "12 only" + * rule into the FFI contract (that limit lives in the ImportPhrase grid). */ +const VALID_WORD_COUNTS: ReadonlySet = new Set([12, 15, 18, 21, 24]); + /** * Models the Rust phrase gate's CONTRACT, not real BIP-39: throws an - * InvalidInput-tagged FfiError for an implausible phrase (not exactly 12 - * non-empty words, or containing the non-word marker `zzzz`), returns void - * otherwise. The genuine checksum validation is the on-device / core-test - * proof; this only lets orchestration tests drive the ok / invalid branches. + * InvalidInput-tagged FfiError for an implausible phrase (not a valid BIP-39 + * length, or containing the non-word marker `zzzz`), returns void otherwise. + * The genuine checksum validation is the on-device / core-test proof; this only + * lets orchestration tests drive the ok / invalid branches. */ export function validateMnemonic(phrase: string): void { const words = phrase @@ -81,7 +86,7 @@ export function validateMnemonic(phrase: string): void { .toLowerCase() .split(/\s+/) .filter((word) => word.length > 0); - if (words.length !== 12 || words.includes('zzzz')) { + if (!VALID_WORD_COUNTS.has(words.length) || words.includes('zzzz')) { throw ffiError('invalid mnemonic phrase', 'InvalidInput'); } } diff --git a/src/screens/onboarding/ConfirmPin.tsx b/src/screens/onboarding/ConfirmPin.tsx index f1b8a00..9f372ee 100644 --- a/src/screens/onboarding/ConfirmPin.tsx +++ b/src/screens/onboarding/ConfirmPin.tsx @@ -14,13 +14,12 @@ import { useState } from 'react'; import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { assertNever } from '../../lib/assertNever'; import { PinKeypad } from '../../components/PinKeypad'; import { useOnboardingStore, type ImportCommitResult } from '../../stores/onboardingStore'; -import { themes } from '../../theme/theme'; +import { SPINNER_COLOR } from '../../theme/theme'; const PIN_LENGTH = 6; -// ActivityIndicator takes a color prop, not a className (SplashScreen pattern). -const SPINNER_COLOR = themes.dark.accent; function importErrorMessage(result: Exclude): string { switch (result) { @@ -30,6 +29,8 @@ function importErrorMessage(result: Exclude): string { return 'Could not import the wallet. Please try again.'; case 'storage': return 'Could not save the wallet. Please try again.'; + default: + return assertNever(result); } } diff --git a/src/screens/onboarding/ImportPhrase.tsx b/src/screens/onboarding/ImportPhrase.tsx index 50ec3c1..1d4b840 100644 --- a/src/screens/onboarding/ImportPhrase.tsx +++ b/src/screens/onboarding/ImportPhrase.tsx @@ -27,11 +27,10 @@ import { ActivityIndicator, BackHandler, Pressable, ScrollView, Text, TextInput, import { isWordlistMember, splitPastedPhrase, suggestWords } from '../../lib/phraseInput'; import { useOnboardingStore } from '../../stores/onboardingStore'; -import { themes } from '../../theme/theme'; +import { SPINNER_COLOR } from '../../theme/theme'; const WORD_COUNT = 12; const EMPTY_WORDS: readonly string[] = Array.from({ length: WORD_COUNT }, () => ''); -const SPINNER_COLOR = themes.dark.accent; const OVER_LENGTH_MESSAGE = 'Only 12-word phrases are supported'; const INVALID_MESSAGE = 'This is not a valid recovery phrase'; diff --git a/src/screens/onboarding/__tests__/ImportPhrase.test.tsx b/src/screens/onboarding/__tests__/ImportPhrase.test.tsx index 479aacb..73354f5 100644 --- a/src/screens/onboarding/__tests__/ImportPhrase.test.tsx +++ b/src/screens/onboarding/__tests__/ImportPhrase.test.tsx @@ -1,3 +1,4 @@ +import { BackHandler } from 'react-native'; import TestRenderer, { act } from 'react-test-renderer'; import { useOnboardingStore } from '../../../stores/onboardingStore'; @@ -101,6 +102,55 @@ describe('ImportPhrase', () => { } }); + it('rejects an over-length paste measured from the FOCUSED field, not just by word count', () => { + // Pins the `index + tokens.length > 12` overflow guard: 8 words is <=12, so + // an index-blind check would pass and write out of bounds; pasting 8 into + // field 6 (6+8=14) must still reject whole. + act(() => { + tree = TestRenderer.create(); + }); + const eight = Array.from({ length: 8 }, () => 'abandon').join(' '); + + typeInto(6, eight); + + expect(tree!.root.findByProps({ testID: 'import-error' }).props.children).toBe( + 'Only 12-word phrases are supported', + ); + for (let i = 0; i < 12; i += 1) { + expect(field(i).props.value).toBe(''); + } + }); + + it('consumes hardware back by cancelling the flow (raw fields never survive)', () => { + const remove = jest.fn(); + let capturedHandler: (() => boolean) | undefined; + jest.spyOn(BackHandler, 'addEventListener').mockImplementation((_event, handler) => { + capturedHandler = handler as () => boolean; + return { remove } as ReturnType; + }); + act(() => { + tree = TestRenderer.create(); + }); + typeInto(0, 'abandon'); // a raw word is in local state + + let consumed: boolean | undefined; + act(() => { + consumed = capturedHandler!(); + }); + + expect(consumed).toBe(true); // back is consumed, not propagated + const state = useOnboardingStore.getState(); + expect(state.step).toBe('welcome'); // cancel() ran + expect(state.flow).toBe('create'); + expect(state.mnemonic).toBeNull(); // nothing typed leaked into the store + + act(() => { + tree!.unmount(); + tree = undefined; + }); + expect(remove).toHaveBeenCalledTimes(1); + }); + it('offers wordlist suggestions and fills a field only on an explicit tap', () => { act(() => { tree = TestRenderer.create(); diff --git a/src/stores/__tests__/onboardingStore.test.ts b/src/stores/__tests__/onboardingStore.test.ts index f0e9a17..d64f9b0 100644 --- a/src/stores/__tests__/onboardingStore.test.ts +++ b/src/stores/__tests__/onboardingStore.test.ts @@ -274,6 +274,18 @@ describe('useOnboardingStore', () => { }); }); + it('cancel wipes a validated import mnemonic and resets flow to create', async () => { + useOnboardingStore.getState().beginImport(); + await useOnboardingStore.getState().submitImportPhrase(VALID_PHRASE); // mnemonic now set + + useOnboardingStore.getState().cancel(); + + const state = useOnboardingStore.getState(); + expect(state.mnemonic).toBeNull(); + expect(state.flow).toBe('create'); + expect(state.step).toBe('welcome'); + }); + describe('completeImport', () => { it('on success: commits, publishes the session, and wipes the mnemonic', async () => { useOnboardingStore.setState({ flow: 'import', mnemonic: VALID_PHRASE, step: 'confirmPin' }); diff --git a/src/theme/theme.ts b/src/theme/theme.ts index 0dc4c25..8334781 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -122,6 +122,13 @@ const light: SemanticColors = new Proxy({} as SemanticColors, { export const themes: Record = { dark, light }; +/** + * Shared spinner color for `ActivityIndicator` (which takes a `color` prop, not + * a `className`). Dark-only for now, matching the app. Single source so screens + * don't each re-declare `themes.dark.accent`. + */ +export const SPINNER_COLOR = dark.accent; + /** Resolve a risk tier to its foreground/background/border triple. */ export function riskColors(tier: RiskTier, c: SemanticColors = dark) { switch (tier) {