Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions __mocks__/react-native-rustok-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,53 @@ 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;

/** 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<number> = 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 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
.trim()
.toLowerCase()
.split(/\s+/)
.filter((word) => word.length > 0);
if (!VALID_WORD_COUNTS.has(words.length) || words.includes('zzzz')) {
throw ffiError('invalid mnemonic phrase', 'InvalidInput');
}
}

class MockWallet {
constructor(
private readonly addr: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -2658,6 +2680,16 @@ jsi::Value NativeRustokMobileBindings::cpp_uniffi_rustok_mobile_bindings_fn_func

return uniffi::rustok_mobile_bindings::Bridging<RustBuffer>::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<RustCallStatus>::rustSuccess(rt);
uniffi_rustok_mobile_bindings_fn_func_validate_mnemonic(uniffi::rustok_mobile_bindings::Bridging<RustBuffer>::fromJs(rt, callInvoker, args[0]),
&status
);
uniffi::rustok_mobile_bindings::Bridging<RustCallStatus>::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</*handle*/ uint64_t>::fromJs(rt, callInvoker, args[0]), uniffi::rustok_mobile_bindings::Bridging<UniffiRustFutureContinuationCallback>::fromJs(rt, callInvoker, args[1]), uniffi_jsi::Bridging</*handle*/ uint64_t>::fromJs(rt, callInvoker, args[2])
);
Expand Down Expand Up @@ -3035,6 +3067,13 @@ jsi::Value NativeRustokMobileBindings::cpp_uniffi_rustok_mobile_bindings_checksu
);


return uniffi_jsi::Bridging<uint16_t>::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<uint16_t>::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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/lib/__tests__/bip39Wordlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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) {
// 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');
}

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');
});
});
87 changes: 87 additions & 0 deletions src/lib/__tests__/phraseInput.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading