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
25 changes: 22 additions & 3 deletions mobile_app/src/infrastructure/wallet/LocalWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as LocalAuthentication from 'expo-local-authentication';
import { TurboModuleRegistry, type TurboModule } from 'react-native';
import {
SecureKeys, PrefKeys,
secureGet, secureGetStrict, secureSet, secureDeleteAll,
secureGetStrict, secureSet, secureDeleteAll,
prefGet,
} from '@/src/storage';
import type { IWalletAdapter, WalletMode } from './types';
Expand Down Expand Up @@ -120,7 +120,16 @@ export class LocalWallet implements IWalletAdapter {
if (!auth.success) throw new Error('Authentication cancelled');
}

const stored = await secureGet(SecureKeys.WALLET_PUBKEY);
// Strict read: a transient keystore failure must surface as a retryable
// error, NOT as "no wallet" — the absent message tells the user to
// recreate, which is exactly the wrong advice while their keys are intact.
let stored: string | null;
try {
stored = await secureGetStrict(SecureKeys.WALLET_PUBKEY);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`Keychain read failed: ${msg} — your wallet is still on this device. Try again in a moment.`);
}
if (!stored) throw new Error('No local wallet found — please recreate your wallet');
this._publicKey = new PublicKey(stored);
}
Expand All @@ -133,8 +142,18 @@ export class LocalWallet implements IWalletAdapter {
return (await readAndDecrypt()).secretKey;
}

/**
* True when the wallet marker is verifiably present.
*
* Reads with secureGetStrict, so a Keychain/Keystore read FAILURE throws
* instead of returning false. The conflation was the last QA-20 hole: a
* transient keystore outage (device just unlocked, cross-process lock) made
* exists() report "no wallet", which routed the user to onboarding where
* create() would overwrite the real, funded keypair. Callers must treat a
* throw as "unknown — wallet may exist" and never create/delete on it.
*/
static async exists(): Promise<boolean> {
return (await secureGet(SecureKeys.WALLET_MARKER)) === 'true';
return (await secureGetStrict(SecureKeys.WALLET_MARKER)) === 'true';
}

/**
Expand Down
24 changes: 22 additions & 2 deletions mobile_app/src/infrastructure/wallet/WalletFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,17 @@ export const WalletFactory = {

async hasLocalWallet(): Promise<boolean> {
if (DeviceDetector.isSolanaMobileDevice()) return MWAWallet.hasCachedToken();
if (!await LocalWallet.exists()) return false;
let exists: boolean;
try {
exists = await LocalWallet.exists();
} catch {
// Marker read FAILED — wallet presence is unknown, which must never be
// reported as "no wallet": that routes the user to onboarding, where
// create() would overwrite a real, funded keypair (QA-20's last hole).
// Report "present" and let connect() surface the retryable read error.
return true;
}
if (!exists) return false;
const integrity = await LocalWallet.isFullyIntact();
// ONLY delete when the keys are verifiably absent (marker present but
// secret/AES key genuinely gone — e.g. cross-build keychain access-group
Expand Down Expand Up @@ -45,7 +55,17 @@ export const WalletFactory = {

async createLocal(): Promise<LocalWallet> {
// Guard: reconnect if wallet already exists rather than overwriting keypair.
if (await LocalWallet.exists()) {
// exists() throws on a keychain read failure — when we cannot VERIFY there
// is no wallet, creating one would overwrite the stored secret of a wallet
// that may well exist. Fail the create instead; nothing is written.
let exists: boolean;
try {
exists = await LocalWallet.exists();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`Keychain read failed: ${msg} — can't verify whether a wallet already exists, so nothing was created or changed. Try again in a moment.`);
}
if (exists) {
const integrity = await LocalWallet.isFullyIntact();
// Only recreate when storage is verifiably partial — otherwise the
// wallet cannot export or sign and re-onboarding is the only fix.
Expand Down
Loading