From 11df705d09f5435a88f286a09b7b25986d372ddd Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:32:41 -0800 Subject: [PATCH 1/8] fix(wallet): disable device-PIN fallback on all LocalAuthentication calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anyone with the device unlock pattern could previously extract the seed in four taps — biometric fell through to the device PIN/pattern instead of failing closed. Flips disableDeviceFallback to true on all three LocalAuthentication.authenticateAsync sites in LocalWallet (unlock, sign-time check via readAndDecrypt, create-wallet). Closes AUDIT T13 / THREAT_MODEL T-WALLET-01 / ROADMAP § 0.2. --- mobile_app/src/infrastructure/wallet/LocalWallet.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile_app/src/infrastructure/wallet/LocalWallet.ts b/mobile_app/src/infrastructure/wallet/LocalWallet.ts index 696abefb..1957e43d 100644 --- a/mobile_app/src/infrastructure/wallet/LocalWallet.ts +++ b/mobile_app/src/infrastructure/wallet/LocalWallet.ts @@ -52,7 +52,7 @@ function aesDecrypt(aesKey: Uint8Array, payload: StoredPayload): Uint8Array { async function readAndDecrypt(): Promise { const auth = await LocalAuthentication.authenticateAsync({ promptMessage: 'Authenticate to export your private key', - disableDeviceFallback: false, + disableDeviceFallback: true, cancelLabel: 'Cancel', }); if (!auth.success) throw new Error('Authentication cancelled'); @@ -104,7 +104,7 @@ export class LocalWallet implements IWalletAdapter { if (needsBiometric) { const auth = await LocalAuthentication.authenticateAsync({ promptMessage: 'Unlock your anonmesh wallet', - disableDeviceFallback: false, + disableDeviceFallback: true, cancelLabel: 'Cancel', }); if (!auth.success) throw new Error('Authentication cancelled'); @@ -141,7 +141,7 @@ export class LocalWallet implements IWalletAdapter { static async create(): Promise { const auth = await LocalAuthentication.authenticateAsync({ promptMessage: 'Authenticate to create your anonmesh wallet', - disableDeviceFallback: false, + disableDeviceFallback: true, cancelLabel: 'Cancel', }); if (!auth.success) throw new Error('Authentication cancelled'); From 55091e6aff663258cf15249fc5223371e75a0c9b Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:34:47 -0800 Subject: [PATCH 2/8] fix(wallet): drop dead error state in useWalletBalance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecentActivity has read the rate-limit signal off activityError === 'Devnet rate-limited' for a while now. The hook still carried a second error field that set 'Couldn't reach devnet' when every fetch failed — nothing in the app ever read it, so the careful copy was unreachable per AUDIT T24. Removes the dead state from the interface + provider value + refetch reset. The 429 path now flows entirely through activityError so the consumer copy stays canonical. --- mobile_app/src/hooks/useWalletBalance.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/mobile_app/src/hooks/useWalletBalance.tsx b/mobile_app/src/hooks/useWalletBalance.tsx index 336d110f..6844757f 100644 --- a/mobile_app/src/hooks/useWalletBalance.tsx +++ b/mobile_app/src/hooks/useWalletBalance.tsx @@ -18,7 +18,6 @@ interface WalletBalanceState { activityLoading: boolean; activityError: string | null; loading: boolean; - error: string | null; refetch: () => Promise; lastFetched: number | null; } @@ -42,7 +41,6 @@ export function WalletBalanceProvider({ children }: { children: ReactNode }) { const [activityLoading, setActivityLoading] = useState(false); const [activityError, setActivityError] = useState(null); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [lastFetched, setLastFetched] = useState(null); const lastFetchedRef = useRef(null); @@ -78,7 +76,7 @@ export function WalletBalanceProvider({ children }: { children: ReactNode }) { setTokens([NATIVE_SOL]); setSolBalance(null); setActivity([]); - setError(null); + setActivityError(null); return; } @@ -100,14 +98,13 @@ export function WalletBalanceProvider({ children }: { children: ReactNode }) { applyBalanceResults(solResult, splResult); applyActivityResult(activityResult); - const allFailed = - solResult.status === "rejected" && - splResult.status === "rejected" && - activityResult.status === "rejected"; - setError(allFailed ? "Couldn't reach devnet" : null); + // When every fetch fails (e.g. devnet 429), activityError already + // carries the canonical "Devnet rate-limited" string for RecentActivity. + // Surfacing a duplicate `error` field here just drifts: nothing reads it. setLastFetched(Date.now()); } catch (err) { - setError(err instanceof Error ? err.message : "Couldn't refresh balance — pull to retry"); + const msg = err instanceof Error ? err.message : String(err); + setActivityError(msg.includes("429") ? "Devnet rate-limited" : "Couldn't load activity"); } finally { setLoading(false); setActivityLoading(false); @@ -140,7 +137,6 @@ export function WalletBalanceProvider({ children }: { children: ReactNode }) { activityLoading, activityError, loading, - error, refetch, lastFetched, }; From 0de1b80a855553230bc91990bc1d8ddb063486f7 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:35:30 -0800 Subject: [PATCH 3/8] fix(send): drop hardcoded devnet from SuccessCard + ReviewCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settlement subtitle and fee secondary line both said 'devnet' verbatim — fine today, wrong the moment mainnet ships. SuccessCard now reads 'Settlement confirmed on-chain.', fee row reads 'Estimated from network RPC'. Banner / explorer link still disclose the active network. --- mobile_app/components/send/ReviewCard.tsx | 2 +- mobile_app/components/send/SuccessCard.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index 7b8adfc5..8d43239d 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -398,7 +398,7 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI colors={colors} icon="zap" label="Fee" - secondary="Estimated from devnet RPC" + secondary="Estimated from network RPC" value={feeLabel} /> diff --git a/mobile_app/components/send/SuccessCard.tsx b/mobile_app/components/send/SuccessCard.tsx index 83949646..2da6e59f 100644 --- a/mobile_app/components/send/SuccessCard.tsx +++ b/mobile_app/components/send/SuccessCard.tsx @@ -119,7 +119,7 @@ export function SuccessCard({ txId, amount, symbol }: SuccessCardProps) { showBack={false} eyebrow="Transfer receipt" title="sent" - subtitle="Settlement confirmed on devnet." + subtitle="Settlement confirmed on-chain." footer={ } From b3207a43583b2c764b3b3b9f8be55b96e0b2965c Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:36:09 -0800 Subject: [PATCH 4/8] fix(send): silent user-cancel on TransactionNotApprovedError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user dismissed the wallet popup we still painted an 'Approve the transaction in your wallet' banner — which reads like an error, not a cancel. Solana Mobile's own guidance says swallow those (LESSON 2026-05-13, ROADMAP § 2.3). Branches before setError on isUserCancel and skips it; slider + phase still reset so the user can retry. Real failures still show the error card. --- mobile_app/components/send/ReviewCard.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index 8d43239d..62358f22 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -310,14 +310,12 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI networkMode: rpcAdapter.mode, userCancel: isUserCancel, }); - setError( - isUserCancel - ? { - kind: "approval", - message: "Approve the transaction in your wallet to submit it.", - } - : { kind: "send", message: summary.message }, - ); + // User-cancel returns silently per Solana Mobile guidance (LESSON + // 2026-05-13) — the wallet popup is the consent surface, an inline banner + // double-prompts and reads like an error. + if (!isUserCancel) { + setError({ kind: "send", message: summary.message }); + } setSliderResetKey((k) => k + 1); setIsConfirming(false); setTxPhase(null); From 8c1536576f4fec591584eb2a4ba2d84ff8c98c44 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Fri, 15 May 2026 00:58:08 -0800 Subject: [PATCH 5/8] fix(send): MAX chip subtracts fee buffer on native SOL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAX previously paid the full balance, so the resulting tx instantly failed at simulate-time on full-balance wallets. Now reserves a 0.001 SOL buffer on native SOL only — covers signature fee (~5k lamports), priority spikes, recipient rent-exempt minimum (~890k lamports) when sending to a brand-new wallet, and any rounding between uiAmount display and underlying lamport count. SPL paths still send the full token balance since SPL fees come out of SOL. --- mobile_app/components/send/AmountKeypad.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/mobile_app/components/send/AmountKeypad.tsx b/mobile_app/components/send/AmountKeypad.tsx index 20ddda05..55116243 100644 --- a/mobile_app/components/send/AmountKeypad.tsx +++ b/mobile_app/components/send/AmountKeypad.tsx @@ -62,8 +62,17 @@ export function AmountKeypad() { }, [recipient, router]); const balanceNum = token.uiAmount; + // Reserve enough headroom to cover: signature fee (~5k lamports), priority + // fees if added later, recipient-account rent-exempt minimum (~890k lamports) + // when sending to a brand-new wallet, and any rounding between the uiAmount + // display and the underlying lamport count. 0.001 SOL = 1M lamports — costs + // the user a tiny crumb on MAX but makes the send reliable. + // SPL transfers pay fees in SOL so their MAX is the full token balance. + const SOL_FEE_BUFFER = 0.001; + const isNativeSol = !mint || mint === ""; + const maxSendable = isNativeSol ? Math.max(0, balanceNum - SOL_FEE_BUFFER) : balanceNum; const amountNum = Number.parseFloat(amount) || 0; - const isValid = amountNum > 0 && amountNum <= balanceNum && Boolean(recipient); + const isValid = amountNum > 0 && amountNum <= maxSendable && Boolean(recipient); function handleNext() { if (!isValid) return; @@ -123,7 +132,7 @@ export function AmountKeypad() { {/* Overage warning */} - {amountNum > balanceNum ? ( + {amountNum > maxSendable ? ( From 82b798f27c00211e58d1d2c49b3cb3c579fc40d8 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:38:03 -0800 Subject: [PATCH 6/8] fix(settings): biometric re-auth on rotate identity confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rotate identity is irreversible — old peers can't reach you again. Was triggerable from any unlocked session with one tap. Now requires a fresh biometric prompt before the rotate fires, matching the seed-export gate. Falls back to the existing confirm path if biometric is disabled / unavailable so users don't get locked out. --- .../components/settings/RotateKeypairModal.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/mobile_app/components/settings/RotateKeypairModal.tsx b/mobile_app/components/settings/RotateKeypairModal.tsx index 77175423..02efb98e 100644 --- a/mobile_app/components/settings/RotateKeypairModal.tsx +++ b/mobile_app/components/settings/RotateKeypairModal.tsx @@ -2,9 +2,11 @@ import React, { useState, useRef, useEffect } from 'react'; import { View, Text, Pressable, Modal, StyleSheet, Animated } from 'react-native'; import Reanimated, { FadeIn } from 'react-native-reanimated'; import { Feather } from '@expo/vector-icons'; +import * as LocalAuthentication from 'expo-local-authentication'; import { fontFamily, useTheme } from '@/theme'; import { useGlass } from '@/hooks/useGlass'; import { useLxmfContext } from '@/context/LxmfContext'; +import { useBiometricEnabled } from '@/hooks/useBiometricEnabled'; const ROTATE_LINES = [ 'wiping ed25519 keypair from secure store…', @@ -21,6 +23,7 @@ export function RotateKeypairModal({ onClose }: { onClose: () => void }) { const softGlass = useGlass('soft'); const { resetIdentity, status } = useLxmfContext(); + const [biometricEnabled] = useBiometricEnabled(); const [phase, setPhase] = useState<0 | 1 | 2>(0); // 0=confirm 1=rotating 2=done const [newAddr, setNewAddr] = useState(''); @@ -46,6 +49,21 @@ export function RotateKeypairModal({ onClose }: { onClose: () => void }) { }; const confirmRotate = async () => { + // Re-auth gate so the irreversible step requires a fresh biometric — the + // SIGN OUT / rotate confirm row is otherwise reachable from any unlocked + // session. + if (biometricEnabled) { + const hasHw = await LocalAuthentication.hasHardwareAsync(); + const enrolled = await LocalAuthentication.isEnrolledAsync(); + if (hasHw && enrolled) { + const auth = await LocalAuthentication.authenticateAsync({ + promptMessage: 'Authenticate to rotate your anonmesh identity', + disableDeviceFallback: true, + cancelLabel: 'Cancel', + }); + if (!auth.success) return; + } + } setPhase(1); await resetIdentity(); }; From 62daefae050976e3210f72f0c4940bffa2763a7a Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:38:55 -0800 Subject: [PATCH 7/8] fix(send): cancel affordance in 'Approve in wallet' footer Once MWA was launched and the wallet popup hadn't returned, the user was stuck staring at 'Approve in wallet' with no way out. Adds a small Cancel under the waiting footer that flips isConfirming back, resets txPhase, and re-arms the slider. UX P1 #15. --- mobile_app/components/send/ReviewCard.tsx | 40 ++++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index 62358f22..961d9c30 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -335,11 +335,27 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI title="review" footer={ isConfirming ? ( - - - - Approve in wallet - + + + + + Approve in wallet + + + { + haptics.tap(); + setIsConfirming(false); + setTxPhase(null); + setSliderResetKey((k) => k + 1); + }} + style={S.waitingCancelBtn} + > + Cancel + ) : ( Date: Thu, 14 May 2026 04:39:42 -0800 Subject: [PATCH 8/8] fix(onboarding): create-button shows CREATING during create flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CREATE IDENTITY button rendered 'CONNECTING...' once tapped — copied straight from the CONNECT WALLET label. Shows 'CREATING…' instead so the affordance matches the action. D-LANE NOTE: this file is Djason-owned. Branch stays local until Hunter sends the Djason memo at push-time. --- mobile_app/components/onboarding/CTAButtons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile_app/components/onboarding/CTAButtons.tsx b/mobile_app/components/onboarding/CTAButtons.tsx index efeadb6f..fd6a1faa 100644 --- a/mobile_app/components/onboarding/CTAButtons.tsx +++ b/mobile_app/components/onboarding/CTAButtons.tsx @@ -27,7 +27,7 @@ export const CTAButtons = memo(function CTAButtons({ isLoading, onConnect, onCre style={S.primary} > - {isLoading ? 'CONNECTING...' : 'CREATE IDENTITY'} + {isLoading ? 'CREATING…' : 'CREATE IDENTITY'}