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');