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'} 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 ? ( diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index 7b8adfc5..961d9c30 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); @@ -337,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 + ) : ( @@ -574,6 +588,20 @@ const S = StyleSheet.create({ fontFamily: FF.sansMd, fontSize: 16, }, + waitingFooterStack: { + alignItems: "center", + gap: 6, + }, + waitingCancelBtn: { + paddingHorizontal: 12, + paddingVertical: 4, + }, + waitingCancelText: { + fontFamily: FF.sansMd, + fontSize: 13, + letterSpacing: 0.6, + textTransform: "uppercase", + }, // error errorPanel: { 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={ } 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(); }; 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, }; 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');