Skip to content
2 changes: 1 addition & 1 deletion mobile_app/components/onboarding/CTAButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const CTAButtons = memo(function CTAButtons({ isLoading, onConnect, onCre
style={S.primary}
>
<Text style={S.primaryText}>
{isLoading ? 'CONNECTING...' : 'CREATE IDENTITY'}
{isLoading ? 'CREATING…' : 'CREATE IDENTITY'}
</Text>
</LinearGradient>
</Pressable>
Expand Down
15 changes: 12 additions & 3 deletions mobile_app/components/send/AmountKeypad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +71 to +73
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;
Expand Down Expand Up @@ -123,7 +132,7 @@ export function AmountKeypad() {
<NumericKeypad
currency={token.symbol}
fiatLabel={`${formatBalance(token.uiAmount, token.maxDecimals)} ${token.symbol} available`}
maxAmount={balanceNum.toString()}
maxAmount={maxSendable.toString()}
maxDecimals={token.maxDecimals}
onChangeValue={setAmount}
showMaxChip
Expand All @@ -132,7 +141,7 @@ export function AmountKeypad() {
</View>

{/* Overage warning */}
{amountNum > balanceNum ? (
{amountNum > maxSendable ? (
<View style={S.errorRow}>
<Feather name="alert-circle" size={14} color={colors.error} />
<Text style={[S.errorText, { color: colors.error }]}>
Expand Down
56 changes: 42 additions & 14 deletions mobile_app/components/send/ReviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -337,11 +335,27 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI
title="review"
footer={
isConfirming ? (
<View style={[S.waitingFooter, { backgroundColor: colors.surface1, borderColor: colors.border }]}>
<Feather name="smartphone" size={16} color={colors.primary} />
<Text style={[S.waitingFooterText, { color: colors.textPrimary }]}>
Approve in wallet
</Text>
<View style={S.waitingFooterStack}>
<View style={[S.waitingFooter, { backgroundColor: colors.surface1, borderColor: colors.border }]}>
<Feather name="smartphone" size={16} color={colors.primary} />
<Text style={[S.waitingFooterText, { color: colors.textPrimary }]}>
Approve in wallet
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Cancel approval"
hitSlop={10}
onPress={() => {
haptics.tap();
setIsConfirming(false);
setTxPhase(null);
setSliderResetKey((k) => k + 1);
}}
style={S.waitingCancelBtn}
>
<Text style={[S.waitingCancelText, { color: colors.textTertiary }]}>Cancel</Text>
</Pressable>
</View>
) : (
<SlideToConfirm
Expand Down Expand Up @@ -398,7 +412,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}
/>
</View>
Expand Down Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion mobile_app/components/send/SuccessCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={
<DepthButton label="Done" onPress={handleDone} size="lg" tone="cyan" variant="primary" />
}
Expand Down
18 changes: 18 additions & 0 deletions mobile_app/components/settings/RotateKeypairModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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…',
Expand All @@ -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('');
Expand All @@ -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();
};
Expand Down
16 changes: 6 additions & 10 deletions mobile_app/src/hooks/useWalletBalance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ interface WalletBalanceState {
activityLoading: boolean;
activityError: string | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
lastFetched: number | null;
}
Expand All @@ -42,7 +41,6 @@ export function WalletBalanceProvider({ children }: { children: ReactNode }) {
const [activityLoading, setActivityLoading] = useState(false);
const [activityError, setActivityError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastFetched, setLastFetched] = useState<number | null>(null);

const lastFetchedRef = useRef<number | null>(null);
Expand Down Expand Up @@ -78,7 +76,7 @@ export function WalletBalanceProvider({ children }: { children: ReactNode }) {
setTokens([NATIVE_SOL]);
setSolBalance(null);
setActivity([]);
setError(null);
setActivityError(null);
return;
}

Expand All @@ -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);
Expand Down Expand Up @@ -140,7 +137,6 @@ export function WalletBalanceProvider({ children }: { children: ReactNode }) {
activityLoading,
activityError,
loading,
error,
refetch,
lastFetched,
};
Expand Down
6 changes: 3 additions & 3 deletions mobile_app/src/infrastructure/wallet/LocalWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function aesDecrypt(aesKey: Uint8Array, payload: StoredPayload): Uint8Array {
async function readAndDecrypt(): Promise<Keypair> {
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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -141,7 +141,7 @@ export class LocalWallet implements IWalletAdapter {
static async create(): Promise<LocalWallet> {
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');
Expand Down
Loading