diff --git a/mobileapp/app/mnemonic-backup.tsx b/mobileapp/app/mnemonic-backup.tsx index 3770327..ca2b4bd 100644 --- a/mobileapp/app/mnemonic-backup.tsx +++ b/mobileapp/app/mnemonic-backup.tsx @@ -44,6 +44,55 @@ const WordTile = ({ index, word }: WordTileProps) => ( ); +// ── Verify quiz ───────────────────────────────────────────────────────────── + +/** 1-based positions (3, 7, 11) that the user must confirm to verify backup. */ +const QUIZ_POSITIONS = [3, 7, 11]; + +/** Deterministic-ish scramble helper (seeded per render is fine here). */ +function shuffle(arr: T[]): T[] { + const out = [...arr]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +/** Distinct decoy pool drawn from the rest of the phrase, avoiding dupes. */ +function quizWordOptions(words: string[], position: number): string[] { + const correct = words[position - 1]; + const decoys = shuffle(words.filter((w) => w !== correct)).slice(0, 3); + return shuffle([correct, ...decoys]); +} + +const VerifyButton = ({ + label, + selected, + onPress, +}: { + label: string; + selected: boolean; + onPress: () => void; +}) => ( + + + {label} + + +); + // ── Screen ──────────────────────────────────────────────────────────────────── export default function MnemonicBackupScreen() { @@ -55,6 +104,9 @@ export default function MnemonicBackupScreen() { const [confirmed, setConfirmed] = useState(false); const [copied, setCopied] = useState(false); const [copyCountdown, setCopyCountdown] = useState(null); + const [stage, setStage] = useState<"phrase" | "verify">("phrase"); + const [answers, setAnswers] = useState>({}); + const [quizOptions, setQuizOptions] = useState>({}); // Keep a ref to the clipboard cancel fn so we can clean up on unmount const cancelClipboardRef = useRef<(() => void) | null>(null); @@ -150,16 +202,57 @@ export default function MnemonicBackupScreen() { // ── Continue handler ────────────────────────────────────────────────────── const handleContinue = useCallback(async () => { - if (!confirmed) return; + if (!confirmed || !wallet) return; - // Clear clipboard before navigating away + // Clear clipboard before showing the verify quiz await clearClipboard(); if (countdownRef.current) clearInterval(countdownRef.current); cancelClipboardRef.current = null; + setCopied(false); + + const words = wallet.mnemonic.split(" "); + const options: Record = {}; + for (const pos of QUIZ_POSITIONS) + options[pos] = quizWordOptions(words, pos); + + // Ask the user to confirm a few phrase words before wallet unlock. + setQuizOptions(options); + setAnswers({}); + setStage("verify"); + }, [confirmed, wallet]); + + // ── Verify handler ─────────────────────────────────────────────────────── + // Only unlocks when the selected word for every quiz position is an exact + // match with the generated phrase. + + const checkAnswers = useCallback( + (a: Record) => + QUIZ_POSITIONS.every( + (pos) => a[pos] === wallet?.mnemonic.split(" ")[pos - 1] + ), + [wallet] + ); - // Navigate to account-type selection (wallet is already stored) - router.replace("/account-type"); - }, [confirmed, router]); + const handleSelect = useCallback( + (pos: number, word: string) => { + const next = { ...answers, [pos]: word }; + setAnswers(next); + if (checkAnswers(next)) { + // Navigate to account-type selection once verified. + router.replace("/account-type"); + } else if ( + Object.keys(next).length === QUIZ_POSITIONS.length && + !checkAnswers(next) + ) { + Alert.alert( + "Incorrect phrase", + "One or more selected words don't match your recovery phrase. Please review and try again.", + [{ text: "OK" }] + ); + } + }, + [answers, checkAnswers, router] + ); // ── Render ──────────────────────────────────────────────────────────────── @@ -213,93 +306,140 @@ export default function MnemonicBackupScreen() { contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} > - {/* Warning banner */} - - - - Write these words down on paper. Never share them with anyone. - Screenshots are blocked for your security. - - - - {/* Word grid */} - - {words.map((word, i) => ( - - ))} - - - {/* Copy button */} - - - - {copied - ? `Copied — clears in ${copyCountdown}s` - : "Copy to Clipboard"} - - + {stage === "phrase" ? ( + <> + {/* Warning banner */} + + + + Write these words down on paper. Never share them with anyone. + Screenshots are blocked for your security. + + + + {/* Word grid */} + + {words.map((word, i) => ( + + ))} + + + {/* Copy button */} + + + + {copied + ? `Copied — clears in ${copyCountdown}s` + : "Copy to Clipboard"} + + + + {/* Confirmation checkbox */} + setConfirmed((v) => !v)} + activeOpacity={0.8} + accessibilityRole="checkbox" + accessibilityState={{ checked: confirmed }} + > + + {confirmed && ( + + )} + + + I have written down my recovery phrase and stored it safely. I + understand that losing it means losing access to my wallet + forever. + + + + {/* Account info */} + + + + Public key:{" "} + + {wallet.accounts[0].publicKey.slice(0, 8)}… + {wallet.accounts[0].publicKey.slice(-8)} + + + + + ) : ( + /* Verify quiz — confirm words at positions 3, 7, 11 before unlock */ + + + Verify your recovery phrase + + Select the correct word for each position to confirm you've saved + your phrase. + - {/* Confirmation checkbox */} - setConfirmed((v) => !v)} - activeOpacity={0.8} - accessibilityRole="checkbox" - accessibilityState={{ checked: confirmed }} - > - - {confirmed && ( - - )} + {QUIZ_POSITIONS.map((pos) => { + const options = quizOptions[pos] ?? []; + return ( + + Word #{pos} + + {options.map((word) => ( + handleSelect(pos, word)} + /> + ))} + + + ); + })} - - I have written down my recovery phrase and stored it safely. I - understand that losing it means losing access to my wallet forever. - - - - {/* Account info */} - - - - Public key:{" "} - - {wallet.accounts[0].publicKey.slice(0, 8)}… - {wallet.accounts[0].publicKey.slice(-8)} - - - + )} {/* Footer CTA */} -