From 3cd34f3da1c2cbf971f84aa6cc14b2fb8206fc86 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Sun, 3 May 2026 01:22:04 -0800 Subject: [PATCH 01/30] fix: improve lxmf startup and peer counts --- mobile_app/.env.example | 3 + mobile_app/app.json | 1 + mobile_app/app/_layout.tsx | 12 +- .../components/home/NearbyPeersCard.tsx | 7 +- .../components/nodes/BeaconRegistry.tsx | 60 +++++---- mobile_app/context/LxmfContext.tsx | 124 +++++++++++++++--- .../withDisableAndroidContentCapture.js | 31 +++++ 7 files changed, 185 insertions(+), 53 deletions(-) create mode 100644 mobile_app/plugins/withDisableAndroidContentCapture.js diff --git a/mobile_app/.env.example b/mobile_app/.env.example index a116ecbd..82d1d141 100644 --- a/mobile_app/.env.example +++ b/mobile_app/.env.example @@ -1,5 +1,8 @@ +# Optional local Reticulum/LXMF TCP bridge. Use a real LAN IP for a physical +# phone. `localhost` and placeholder hosts are ignored to avoid retry storms. EXPO_PUBLIC_LOCAL_LXMF_HOST=192.168.x.x EXPO_PUBLIC_LOCAL_LXMF_PORT=4243 +EXPO_PUBLIC_LXMF_LOG_LEVEL=1 # Copy to `.env` (gitignored) and fill in before running. # All values with EXPO_PUBLIC_ prefix are inlined at build time — never put # secrets here that you wouldn't ship in a public bundle. diff --git a/mobile_app/app.json b/mobile_app/app.json index c8a203a2..ef8882af 100644 --- a/mobile_app/app.json +++ b/mobile_app/app.json @@ -78,6 +78,7 @@ ], "expo-secure-store", "expo-local-authentication", + "./plugins/withDisableAndroidContentCapture", [ "expo-notifications", { diff --git a/mobile_app/app/_layout.tsx b/mobile_app/app/_layout.tsx index 84e51de7..a471c7be 100644 --- a/mobile_app/app/_layout.tsx +++ b/mobile_app/app/_layout.tsx @@ -57,6 +57,13 @@ const E = StyleSheet.create({ text: { flex: 1, fontSize: 12, lineHeight: 17 }, }); +function NotificationBridge({ onInApp }: { readonly onInApp: (n: NotificationPayload) => void }) { + const [notifsEnabled] = useNotificationEnabled(); + useMessageNotifications(onInApp, notifsEnabled); + usePeerCountNotification(notifsEnabled); + return null; +} + function AppShell() { const router = useRouter(); const { colors } = useTheme(); @@ -75,10 +82,6 @@ function AppShell() { setActiveNotif(n); }, []); - const [notifsEnabled] = useNotificationEnabled(); - useMessageNotifications(handleInApp, notifsEnabled); - usePeerCountNotification(notifsEnabled); - return ( @@ -96,6 +99,7 @@ function AppShell() { + EPOCH_MS_THRESHOLD ? lastAnnounce : lastAnnounce * 1000; +} interface Props { readonly initialActive?: boolean; @@ -24,15 +27,24 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini const softGlass = useGlass('soft'); const accentGlass = useGlass('accent'); const { isBeacon, setBeaconMode, beacons, peers } = useLxmfContext(); - const reachableCount = beacons.filter(b => Date.now() - b.lastAnnounce < BEACON_STALE_MS).length - + peers.filter(p => p.online).length; + const reachableCount = useMemo(() => { + const now = Date.now(); + const reachable = new Set(); + for (const b of beacons) { + if (b.state === 'active' && now - announceMillis(b.lastAnnounce) < BEACON_STALE_MS) { + reachable.add(b.destHash); + } + } + for (const p of peers) { + if (p.online) reachable.add(p.destHash); + } + return reachable.size; + }, [beacons, peers]); const { mode: networkMode } = useNetworkMode(); const hasInternet = networkMode === 'online'; const active = isBeacon; const [modal, setModal] = useState(false); - const [cosigns] = useState(24); - const [earned] = useState(0.000312); const sheetAnim = useRef(new Animated.Value(0)).current; @@ -67,7 +79,7 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini - {earned.toFixed(6)} + 0.000000 SOL EARNED @@ -78,7 +90,7 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini {/* Co-signs */} - {cosigns} + 0 CO-SIGNS @@ -92,7 +104,7 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini - {STAKE_SOL} SOL locked + local mode @@ -115,17 +127,17 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini {reachableCount} - ACTIVE BEACONS + REACHABLE - {STAKE_SOL} SOL - STAKE REQUIRED + 0 SOL + STAKE HELD - Stake SOL to become a beacon node. Co-sign confidential transactions and earn fees from the network. + Enable local beacon announces so other nodes can discover this device. Staking and fee payouts are not wired in this build. {([ - { icon: 'lock' as const, bg: colors.primarySubtle, iconColor: colors.primary, label: 'Stake (locked)', val: `${STAKE_SOL} SOL`, valColor: colors.textPrimary }, - { icon: 'zap' as const, bg: colors.surface1, iconColor: colors.textTertiary, label: 'Network fee', val: `${NETWORK_FEE} SOL`, valColor: colors.textTertiary }, - { icon: 'shield' as const, bg: colors.accentSubtle, iconColor: colors.accent, label: 'Role assigned', val: 'Co-signer', valColor: colors.accent }, + { icon: 'radio' as const, bg: colors.primarySubtle, iconColor: colors.primary, label: 'Beacon mode', val: 'Local', valColor: colors.textPrimary }, + { icon: 'zap' as const, bg: colors.surface1, iconColor: colors.textTertiary, label: 'Network fee', val: 'None', valColor: colors.textTertiary }, + { icon: 'shield' as const, bg: colors.accentSubtle, iconColor: colors.accent, label: 'Registry', val: 'Not submitted', valColor: colors.accent }, ] as const).map((row, i) => ( @@ -194,9 +206,9 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini {active && ( {([ - { icon: 'trending-up' as const, bg: colors.accentSubtle, iconColor: colors.accent, label: 'Earnings to date', val: `◎ ${earned.toFixed(6)}`, valColor: colors.accent }, - { icon: 'unlock' as const, bg: colors.surface1, iconColor: colors.textTertiary, label: 'Stake returned', val: `${STAKE_SOL} SOL`, valColor: colors.textPrimary }, - { icon: 'x-circle' as const, bg: colors.error + '18', iconColor: colors.error, label: 'Co-sign role lost', val: 'Immediately', valColor: colors.error }, + { icon: 'trending-up' as const, bg: colors.accentSubtle, iconColor: colors.accent, label: 'Earnings', val: 'Not tracked', valColor: colors.accent }, + { icon: 'unlock' as const, bg: colors.surface1, iconColor: colors.textTertiary, label: 'Stake held', val: '0 SOL', valColor: colors.textPrimary }, + { icon: 'x-circle' as const, bg: colors.error + '18', iconColor: colors.error, label: 'Announces', val: 'Stop', valColor: colors.error }, ] as const).map((row, i) => ( @@ -213,8 +225,8 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini {active - ? `${STAKE_SOL} SOL stake returns to your wallet. Co-sign fees stop immediately.` - : `Your wallet will be charged ${STAKE_SOL} SOL as stake. Authenticate to confirm.`} + ? 'Beacon mode is local announce-only in this build. Turning it off stops this device announcing.' + : 'Beacon mode starts local announces only. No stake transaction or wallet charge is submitted.'} {/* Primary action */} @@ -231,8 +243,8 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini onPress={() => { setBeaconMode(true); dismiss(); }} style={({ pressed }) => [S.signBtn, { backgroundColor: colors.primary, opacity: pressed ? 0.88 : 1 }]} > - - SIGN WITH BIOMETRICS + + ENABLE BEACON )} diff --git a/mobile_app/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx index 38156f2c..58171a41 100644 --- a/mobile_app/context/LxmfContext.tsx +++ b/mobile_app/context/LxmfContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import * as FileSystem from 'expo-file-system/legacy'; +import { InteractionManager } from 'react-native'; import { SecureKeys, LegacySecureKeys, PrefKeys, secureGet, secureSet, secureDelete, secureDeleteAll, @@ -19,6 +20,9 @@ import { generateNickname } from '@/components/onboarding/constants'; import { requestBLEPermissions } from '@/src/utils/blePermissions'; const IDENTITY_SCHEMA_VERSION = 1; +const PEER_FRESH_WINDOW_SEC = 10 * 60; +const MAX_TRACKED_PEERS = 300; +const EPOCH_MS_THRESHOLD = 10_000_000_000; type StoredIdentity = { version: number; @@ -133,6 +137,7 @@ function sliceNewEvents( const first = events[0] ?? null; if (prevFirst !== null && first !== prevFirst) { const oldIdx = events.indexOf(prevFirst); + if (oldIdx === -1) return events; return oldIdx > 0 ? events.slice(0, oldIdx) : []; } return []; @@ -227,7 +232,9 @@ function mergeBeacon( if (b.destHash === ownHash) return false; const existing = map.get(b.destHash); const isOnline = b.state === 'active'; - const lastSeen = b.lastAnnounce > 0 ? b.lastAnnounce : (existing?.lastSeen ?? now); + const lastSeen = b.lastAnnounce > 0 + ? (b.lastAnnounce > EPOCH_MS_THRESHOLD ? b.lastAnnounce / 1000 : b.lastAnnounce) + : (existing?.lastSeen ?? now); const dispName = names[b.destHash] ?? existing?.displayName ?? b.destHash.slice(0, 8); if (existing?.online === isOnline && existing.lastSeen === lastSeen && existing.displayName === dispName) return false; @@ -244,6 +251,43 @@ function mergeBeacon( return true; } +function prunePeerMap(map: PeerMap, now: number, ownHash: string | undefined): boolean { + let changed = false; + + if (ownHash && map.delete(ownHash)) changed = true; + + for (const [hash, peer] of map) { + let lastSeen = peer.lastSeen; + if (lastSeen > EPOCH_MS_THRESHOLD) { + lastSeen = lastSeen / 1000; + map.set(hash, { ...peer, lastSeen }); + changed = true; + } + if (now - lastSeen > PEER_FRESH_WINDOW_SEC) { + map.delete(hash); + changed = true; + } + } + + if (map.size <= MAX_TRACKED_PEERS) return changed; + + const keep = new Set( + Array.from(map.values()) + .sort((a, b) => b.lastSeen - a.lastSeen) + .slice(0, MAX_TRACKED_PEERS) + .map((peer) => peer.destHash), + ); + + for (const hash of map.keys()) { + if (!keep.has(hash)) { + map.delete(hash); + changed = true; + } + } + + return changed; +} + export const G00N_HUB: TcpInterface = { host: 'dfw.us.g00n.cloud', port: 6969 }; export const BELETH_HUB: TcpInterface = { host: 'rns.beleth.net', port: 4242 }; @@ -266,6 +310,28 @@ export interface StoredMessage { files?: { name: string; data: string }[]; } +const LXMF_LOG_LEVEL = Number(process.env.EXPO_PUBLIC_LXMF_LOG_LEVEL ?? 1); +const LXMF_AUTOSTART_DELAY_MS = 1_500; + +function isUsableTcpHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + if (!normalized) return false; + if (normalized === 'localhost') return false; + if (normalized === '0.0.0.0') return false; + if (normalized === '::1') return false; + if (normalized.startsWith('127.')) return false; + if (normalized.includes('x.x')) return false; + return true; +} + +function configuredTcpInterfaces(): TcpInterface[] { + const interfaces = [G00N_HUB, BELETH_HUB]; + if (MY_PC && isUsableTcpHost(MY_PC.host) && Number.isFinite(MY_PC.port) && MY_PC.port > 0) { + interfaces.unshift(MY_PC); + } + return interfaces; +} + export interface LxmfPeer { destHash: string; displayName: string; @@ -353,31 +419,45 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode const lxmf = useLxmf({ identityHex: storedIdentity?.identity_hex ?? 'new', lxmfAddressHex: storedIdentity?.address_hex ?? 'new', - logLevel: __DEV__ ? 2 : 1, + logLevel: Number.isFinite(LXMF_LOG_LEVEL) ? LXMF_LOG_LEVEL : 1, dbPath: (FileSystem.documentDirectory ?? '') + 'lxmf.db', }); const { isNativeAvailable, isRunning, start, stop, getIdentityHex } = lxmf; const startingRef = useRef(false); + const autostartTimerRef = useRef | null>(null); useEffect(() => { if (!isNativeAvailable || isRunning || startingRef.current || displayName === null || !identityHydrated) return; - startingRef.current = true; - // Request BLE permissions first — start() auto-activates BLE hardware - requestBLEPermissions().then(perm => { - if (perm !== 'granted' && perm !== 'not_required') { - startingRef.current = false; - return; + let cancelled = false; + const interaction = InteractionManager.runAfterInteractions(() => { + autostartTimerRef.current = setTimeout(() => { + if (cancelled || isRunning || startingRef.current) return; + startingRef.current = true; + requestBLEPermissions().then(perm => { + if (cancelled || (perm !== 'granted' && perm !== 'not_required')) return false; + return start({ + mode: LxmfNodeMode.ReticulumAndBle, + tcpInterfaces: configuredTcpInterfaces(), + displayName, + identityHex: storedIdentity?.identity_hex ?? 'new', + lxmfAddressHex: storedIdentity?.address_hex ?? 'new', + isBeacon, + }); + }).then(ok => { + if (ok && !cancelled) setBleActive(true); + }).finally(() => { startingRef.current = false; }); + }, LXMF_AUTOSTART_DELAY_MS); + }); + + return () => { + cancelled = true; + if (autostartTimerRef.current) { + clearTimeout(autostartTimerRef.current); + autostartTimerRef.current = null; } - return start({ - mode: LxmfNodeMode.ReticulumAndBle, - tcpInterfaces: [MY_PC, G00N_HUB, BELETH_HUB].filter(Boolean) as TcpInterface[], - displayName, - identityHex: storedIdentity?.identity_hex ?? 'new', - lxmfAddressHex: storedIdentity?.address_hex ?? 'new', - isBeacon, - }).then(ok => { if (ok) setBleActive(true); }); - }).finally(() => { startingRef.current = false; }); + interaction.cancel(); + }; }, [isNativeAvailable, isRunning, start, displayName, identityHydrated, storedIdentity, isBeacon]); // Persist identity after node starts (using getIdentityHex() per new API) @@ -433,12 +513,14 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode prefGetJson(PrefKeys.PEERS_CACHE).then(cached => { if (!cached) return; const map = knownPeersRef.current; + const now = Date.now() / 1000; for (const p of cached) { if (!map.has(p.destHash)) map.set(p.destHash, { ...p, online: false, isBeaconNode: p.isBeaconNode ?? false }); } + prunePeerMap(map, now, lxmf.status?.addressHex); setPeers(Array.from(map.values())); }); - }, []); + }, [lxmf.status?.addressHex]); useEffect(() => { const map = knownPeersRef.current; @@ -463,12 +545,12 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode let { peerChanged, nameChanged } = processNewEvents(newEvts, map, names, now, ownHash, bleActive); - if (ownHash) map.delete(ownHash); - for (const b of lxmf.beacons) { if (mergeBeacon(b, map, names, now, ownHash)) peerChanged = true; } + if (prunePeerMap(map, now, ownHash)) peerChanged = true; + if (nameChanged) setNameMap({ ...names }); if (!peerChanged) return; @@ -487,7 +569,7 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode if (bleActive) return; const ok = await start({ mode: LxmfNodeMode.ReticulumAndBle, - tcpInterfaces: [MY_PC, G00N_HUB, BELETH_HUB].filter(Boolean) as TcpInterface[], + tcpInterfaces: configuredTcpInterfaces(), displayName: displayName ?? '', identityHex: storedIdentity?.identity_hex ?? 'new', lxmfAddressHex: storedIdentity?.address_hex ?? 'new', diff --git a/mobile_app/plugins/withDisableAndroidContentCapture.js b/mobile_app/plugins/withDisableAndroidContentCapture.js new file mode 100644 index 00000000..0f61bde6 --- /dev/null +++ b/mobile_app/plugins/withDisableAndroidContentCapture.js @@ -0,0 +1,31 @@ +const { withMainActivity } = require("@expo/config-plugins"); + +const IMPORT_VIEW = "import android.view.View"; +const CONTENT_CAPTURE_BLOCK = ` + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.decorView.importantForContentCapture = + View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS + } +`; + +function withDisableAndroidContentCapture(config) { + return withMainActivity(config, (mod) => { + if (mod.modResults.language !== "kt") { + return mod; + } + + let contents = mod.modResults.contents; + if (!contents.includes(IMPORT_VIEW)) { + contents = contents.replace("import android.os.Bundle", `import android.os.Bundle\n${IMPORT_VIEW}`); + } + + if (!contents.includes("IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS")) { + contents = contents.replace(" super.onCreate(null)", ` super.onCreate(null)\n${CONTENT_CAPTURE_BLOCK}`); + } + + mod.modResults.contents = contents; + return mod; + }); +} + +module.exports = withDisableAndroidContentCapture; From fb017110d2c34c5d6ab31a3348fddbe6b8a033a2 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:05:47 -0800 Subject: [PATCH 02/30] feat(wallet): add real spl sends --- mobile_app/app/send/review.tsx | 14 +- mobile_app/components/home/RecentActivity.tsx | 49 ++- mobile_app/components/send/AmountKeypad.tsx | 13 +- .../components/send/RecipientPicker.tsx | 7 +- mobile_app/components/send/ReviewCard.tsx | 70 +++- .../components/wallet/TxDetailModal.tsx | 231 +++++++++++ mobile_app/package-lock.json | 372 ++++++++++++++++++ mobile_app/package.json | 1 + mobile_app/src/services/sendTransaction.ts | 249 +++++++++--- mobile_app/src/services/walletData.ts | 129 +++++- 10 files changed, 1039 insertions(+), 96 deletions(-) create mode 100644 mobile_app/components/wallet/TxDetailModal.tsx diff --git a/mobile_app/app/send/review.tsx b/mobile_app/app/send/review.tsx index a556563f..87559125 100644 --- a/mobile_app/app/send/review.tsx +++ b/mobile_app/app/send/review.tsx @@ -4,11 +4,21 @@ import React from "react"; import { ReviewCard } from "@/components/send/ReviewCard"; export default function ReviewScreen() { - const { to, amount, symbol } = useLocalSearchParams<{ + const { to, amount, symbol, mint, decimals } = useLocalSearchParams<{ to: string; amount: string; symbol: string; + mint?: string; + decimals?: string; }>(); - return ; + return ( + + ); } diff --git a/mobile_app/components/home/RecentActivity.tsx b/mobile_app/components/home/RecentActivity.tsx index 8c9447a1..99d02319 100644 --- a/mobile_app/components/home/RecentActivity.tsx +++ b/mobile_app/components/home/RecentActivity.tsx @@ -1,9 +1,9 @@ -import * as Linking from "expo-linking"; -import React from "react"; +import React, { useState } from "react"; import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; import { Icon, Pill, PressSurface } from "@/components/primitives"; import type { PillTone } from "@/components/primitives"; +import { TxDetailModal } from "@/components/wallet/TxDetailModal"; import { useHideBalance } from "@/src/hooks/useHideBalance"; import { useWalletBalance } from "@/src/hooks/useWalletBalance"; import type { ActivityEntry } from "@/src/services/walletData"; @@ -45,6 +45,7 @@ export function RecentActivity({ limit = DEFAULT_LIMIT }: RecentActivityProps) { const { colors, radii, spacing, fontFamily, fontSize } = useTheme(); const { hidden } = useHideBalance(); const { activity, activityLoading, activityError, lastFetched } = useWalletBalance(); + const [selectedTx, setSelectedTx] = useState(null); const initialLoad = activityLoading && lastFetched === null; const visible = activity.slice(0, limit); @@ -102,20 +103,28 @@ export function RecentActivity({ limit = DEFAULT_LIMIT }: RecentActivityProps) { } return ( - - {visible.map((tx) => ( - + <> + + {visible.map((tx) => ( + + setSelectedTx(null)} + /> + ); } @@ -127,6 +136,7 @@ function ActivityRow({ spacing, fontFamily, fontSize, + onPress, }: { hidden: boolean; tx: ActivityEntry; @@ -135,6 +145,7 @@ function ActivityRow({ spacing: ReturnType["spacing"]; fontFamily: ReturnType["fontFamily"]; fontSize: ReturnType["fontSize"]; + onPress: () => void; }) { const isSend = tx.direction === "send"; const amountText = hidden ? HIDDEN_AMOUNT : `${isSend ? "-" : "+"}${formatAmount(tx.amountSol)}`; @@ -144,14 +155,12 @@ function ActivityRow({ function handlePress() { haptics.tap(); - Linking.openURL( - `https://explorer.solana.com/tx/${encodeURIComponent(tx.signature)}?cluster=devnet`, - ).catch(() => undefined); + onPress(); } return ( (); + const { + decimals: decimalsParam, + mint, + symbol: symbolParam, + to, + } = useLocalSearchParams<{ decimals?: string; mint?: string; symbol?: string; to: string }>(); const { tokens } = useWalletBalance(); const symbol = typeof symbolParam === "string" && symbolParam.length > 0 ? symbolParam : "SOL"; const token = tokenByName(symbol, tokens); + const tokenDecimals = + typeof decimalsParam === "string" && decimalsParam.length > 0 + ? Number.parseInt(decimalsParam, 10) + : token.maxDecimals; const [amount, setAmount] = useState("0"); const recipient = typeof to === "string" ? to : ""; @@ -51,6 +60,8 @@ export function AmountKeypad() { pathname: "/send/review", params: { amount, + decimals: String(Number.isFinite(tokenDecimals) ? tokenDecimals : token.maxDecimals), + mint: typeof mint === "string" ? mint : "", symbol: token.symbol, to: recipient, }, diff --git a/mobile_app/components/send/RecipientPicker.tsx b/mobile_app/components/send/RecipientPicker.tsx index 178ed305..3dabd0e3 100644 --- a/mobile_app/components/send/RecipientPicker.tsx +++ b/mobile_app/components/send/RecipientPicker.tsx @@ -102,7 +102,12 @@ export function RecipientPicker() { haptics.confirm(); router.push({ pathname: "/send/amount", - params: { to: trimmedAddress, symbol: token.symbol }, + params: { + decimals: String(token.maxDecimals), + mint: token.mintAddress ?? "", + symbol: token.symbol, + to: trimmedAddress, + }, }); } diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index 24f9cfb5..2eb4fe07 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -10,7 +10,9 @@ import { useWallet } from "@/context/WalletContext"; import * as haptics from "@/src/design-system/haptics"; import { useNetworkMode } from "@/src/hooks/useNetworkMode"; import { + estimateSplTransferFeeLamports, estimateSolTransferFeeLamports, + sendSplTransfer, sendSolTransfer, TransactionNotApprovedError, } from "@/src/services/sendTransaction"; @@ -66,6 +68,8 @@ interface ReviewCardProps { readonly to: string; readonly amount: string; readonly symbol: string; + readonly mintAddress?: string | string[]; + readonly decimals?: string | string[]; } // ── DetailRow ───────────────────────────────────────────────────────────────── @@ -104,7 +108,7 @@ function DetailRow({ icon, label, secondary, value, valueComponent, colors }: De // ── ReviewCard ──────────────────────────────────────────────────────────────── -export function ReviewCard({ to, amount, symbol }: ReviewCardProps) { +export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: ReviewCardProps) { const router = useRouter(); const { colors } = useTheme(); const { wallet } = useWallet(); @@ -115,26 +119,41 @@ export function ReviewCard({ to, amount, symbol }: ReviewCardProps) { const [feeLabel, setFeeLabel] = useState("Calculating..."); const [isConfirming, setIsConfirming] = useState(false); const [sliderResetKey, setSliderResetKey] = useState(0); + const normalizedMint = typeof mintAddress === "string" ? mintAddress : ""; + const tokenDecimals = + typeof decimals === "string" && decimals.length > 0 ? Number.parseInt(decimals, 10) : 6; useEffect(() => { let cancelled = false; async function estimateFee() { - if (symbol !== "SOL" || !wallet) { + if (!wallet) { setFeeLabel("Fee unavailable"); return; } setFeeLabel("Calculating..."); try { - const lamports = await withTimeout( - estimateSolTransferFeeLamports({ - walletAdapter: wallet, - recipientAddress: to, - amountSOL: Number.parseFloat(amount), - }), - FEE_ESTIMATE_TIMEOUT_MS, - ); + const lamports = + symbol === "SOL" + ? await withTimeout( + estimateSolTransferFeeLamports({ + walletAdapter: wallet, + recipientAddress: to, + amountSOL: Number.parseFloat(amount), + }), + FEE_ESTIMATE_TIMEOUT_MS, + ) + : await withTimeout( + estimateSplTransferFeeLamports({ + walletAdapter: wallet, + recipientAddress: to, + amount, + mintAddress: normalizedMint, + decimals: tokenDecimals, + }), + FEE_ESTIMATE_TIMEOUT_MS, + ); if (!cancelled) setFeeLabel(formatSolFee(lamports)); } catch { if (!cancelled) setFeeLabel("Fee unavailable"); @@ -145,13 +164,16 @@ export function ReviewCard({ to, amount, symbol }: ReviewCardProps) { return () => { cancelled = true; }; - }, [amount, symbol, to, wallet]); + }, [amount, normalizedMint, symbol, to, tokenDecimals, wallet]); async function handleConfirm() { if (isConfirming) return; - if (symbol !== "SOL") { - setError({ kind: "unsupported", message: `${symbol} transfers are not implemented yet` }); + if (symbol !== "SOL" && !normalizedMint) { + setError({ + kind: "unsupported", + message: `${symbol} is missing its token mint. Refresh balances and try again.`, + }); setSliderResetKey((k) => k + 1); return; } @@ -175,12 +197,22 @@ export function ReviewCard({ to, amount, symbol }: ReviewCardProps) { setIsConfirming(true); try { - const result = await sendSolTransfer({ - walletAdapter: wallet, - rpcAdapter, - recipientAddress: to, - amountSOL: Number.parseFloat(amount), - }); + const result = + symbol === "SOL" + ? await sendSolTransfer({ + walletAdapter: wallet, + rpcAdapter, + recipientAddress: to, + amountSOL: Number.parseFloat(amount), + }) + : await sendSplTransfer({ + walletAdapter: wallet, + rpcAdapter, + recipientAddress: to, + amount, + mintAddress: normalizedMint, + decimals: tokenDecimals, + }); router.push({ pathname: "/send/success", diff --git a/mobile_app/components/wallet/TxDetailModal.tsx b/mobile_app/components/wallet/TxDetailModal.tsx new file mode 100644 index 00000000..2fed9d67 --- /dev/null +++ b/mobile_app/components/wallet/TxDetailModal.tsx @@ -0,0 +1,231 @@ +import * as Clipboard from "expo-clipboard"; +import * as WebBrowser from "expo-web-browser"; +import React from "react"; +import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { DepthButton, Icon, Pill } from "@/components/primitives"; +import type { ActivityEntry } from "@/src/services/walletData"; +import * as haptics from "@/src/design-system/haptics"; +import { fontFamily as FF, useTheme } from "@/theme"; + +interface TxDetailModalProps { + readonly tx: ActivityEntry | null; + readonly visible: boolean; + readonly onClose: () => void; +} + +function shortAddress(addr: string): string { + if (addr.length <= 18) return addr; + return `${addr.slice(0, 8)}...${addr.slice(-6)}`; +} + +function formatFee(lamports: number | null): string { + if (lamports === null) return "Unavailable"; + return `${(lamports / 1_000_000_000).toFixed(9).replace(/0+$/, "").replace(/\.$/, "")} SOL`; +} + +function formatAmount(tx: ActivityEntry): string { + const sign = tx.direction === "send" ? "-" : "+"; + const amount = + tx.amountSol < 0.001 && tx.amountSol > 0 + ? tx.amountSol.toFixed(6) + : tx.amountSol.toLocaleString("en-US", { maximumFractionDigits: tx.symbol === "SOL" ? 6 : 4 }); + return `${sign}${amount} ${tx.symbol}`; +} + +function explorerUrl(signature: string): string { + return `https://explorer.solana.com/tx/${encodeURIComponent(signature)}?cluster=devnet`; +} + +function DetailRow({ + label, + value, + copyValue, +}: { + readonly label: string; + readonly value: string; + readonly copyValue?: string; +}) { + const { colors } = useTheme(); + + async function handleCopy() { + if (!copyValue) return; + haptics.tap(); + await Clipboard.setStringAsync(copyValue); + } + + return ( + + {label} + + + {value} + + {copyValue ? : null} + + + ); +} + +export function TxDetailModal({ tx, visible, onClose }: TxDetailModalProps) { + const { colors } = useTheme(); + if (!tx) return null; + const activeTx = tx; + + async function handleExplorer() { + haptics.tap(); + await WebBrowser.openBrowserAsync(explorerUrl(activeTx.signature)); + } + + return ( + + + + + + + + + + + + + + + {formatAmount(tx)} + + {tx.direction === "send" ? "Sent to" : "Received from"} {shortAddress(tx.counterparty)} + + + + + + + + + + + {tx.memo ? : null} + {tx.mintAddress ? ( + + ) : null} + + + + + + + + + + + ); +} + +const S = StyleSheet.create({ + backdrop: { + backgroundColor: "rgba(0,0,0,0.68)", + flex: 1, + justifyContent: "flex-end", + }, + sheet: { + borderTopLeftRadius: 22, + borderTopRightRadius: 22, + borderWidth: 1, + maxHeight: "86%", + overflow: "hidden", + }, + handleWrap: { + alignItems: "center", + paddingTop: 10, + }, + handle: { + borderRadius: 2, + height: 4, + opacity: 0.5, + width: 42, + }, + content: { + gap: 16, + padding: 16, + paddingBottom: 24, + }, + header: { + alignItems: "center", + flexDirection: "row", + gap: 12, + }, + iconWrap: { + alignItems: "center", + borderRadius: 18, + height: 44, + justifyContent: "center", + width: 44, + }, + headerText: { + flex: 1, + gap: 2, + minWidth: 0, + }, + title: { + fontFamily: FF.sansBold, + fontSize: 22, + }, + subtitle: { + fontFamily: FF.sans, + fontSize: 13, + }, + details: { + borderRadius: 16, + borderWidth: 1, + overflow: "hidden", + }, + detailRow: { + alignItems: "center", + borderBottomWidth: StyleSheet.hairlineWidth, + flexDirection: "row", + gap: 14, + justifyContent: "space-between", + minHeight: 48, + paddingHorizontal: 14, + paddingVertical: 10, + }, + detailLabel: { + fontFamily: FF.sansMd, + fontSize: 10, + letterSpacing: 1.5, + textTransform: "uppercase", + }, + detailValueWrap: { + alignItems: "center", + flex: 1, + flexDirection: "row", + gap: 8, + justifyContent: "flex-end", + minWidth: 0, + }, + detailValue: { + flexShrink: 1, + fontFamily: FF.mono, + fontSize: 12, + textAlign: "right", + }, + actions: { + flexDirection: "row", + gap: 10, + }, + actionButton: { + flex: 1, + }, +}); diff --git a/mobile_app/package-lock.json b/mobile_app/package-lock.json index 29a9bf4d..21eeb826 100644 --- a/mobile_app/package-lock.json +++ b/mobile_app/package-lock.json @@ -23,6 +23,7 @@ "@react-navigation/native": "^7.1.8", "@solana-mobile/mobile-wallet-adapter-protocol": "^2.2.8", "@solana-mobile/mobile-wallet-adapter-protocol-web3js": "^2.2.8", + "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.4", "@types/three": "^0.184.0", "bs58": "^6.0.0", @@ -1520,6 +1521,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -3787,6 +3789,37 @@ "node": ">=5.10" } }, + "node_modules/@solana/buffer-layout-utils": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout-utils/-/buffer-layout-utils-0.2.0.tgz", + "integrity": "sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==", + "license": "Apache-2.0", + "dependencies": { + "@solana/buffer-layout": "^4.0.0", + "@solana/web3.js": "^1.32.0", + "bigint-buffer": "^1.1.5", + "bignumber.js": "^9.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@solana/codecs": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-2.0.0-rc.1.tgz", + "integrity": "sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-data-structures": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/codecs-strings": "2.0.0-rc.1", + "@solana/options": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, "node_modules/@solana/codecs-core": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-6.8.0.tgz", @@ -3807,6 +3840,82 @@ } } }, + "node_modules/@solana/codecs-data-structures": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-2.0.0-rc.1.tgz", + "integrity": "sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-data-structures/node_modules/@solana/codecs-core": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.0.0-rc.1.tgz", + "integrity": "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-data-structures/node_modules/@solana/codecs-numbers": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.0.0-rc.1.tgz", + "integrity": "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-data-structures/node_modules/@solana/errors": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.0.0-rc.1.tgz", + "integrity": "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.1.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-data-structures/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@solana/codecs-data-structures/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@solana/codecs-numbers": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-6.8.0.tgz", @@ -3854,6 +3963,83 @@ } } }, + "node_modules/@solana/codecs/node_modules/@solana/codecs-core": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.0.0-rc.1.tgz", + "integrity": "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs/node_modules/@solana/codecs-numbers": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.0.0-rc.1.tgz", + "integrity": "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs/node_modules/@solana/codecs-strings": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-2.0.0-rc.1.tgz", + "integrity": "sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22", + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs/node_modules/@solana/errors": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.0.0-rc.1.tgz", + "integrity": "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.1.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@solana/codecs/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@solana/errors": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-6.8.0.tgz", @@ -3899,6 +4085,148 @@ "node": ">=20" } }, + "node_modules/@solana/options": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-2.0.0-rc.1.tgz", + "integrity": "sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-data-structures": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/codecs-strings": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/options/node_modules/@solana/codecs-core": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.0.0-rc.1.tgz", + "integrity": "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/options/node_modules/@solana/codecs-numbers": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.0.0-rc.1.tgz", + "integrity": "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/options/node_modules/@solana/codecs-strings": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-2.0.0-rc.1.tgz", + "integrity": "sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22", + "typescript": ">=5" + } + }, + "node_modules/@solana/options/node_modules/@solana/errors": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.0.0-rc.1.tgz", + "integrity": "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.1.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/options/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@solana/options/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@solana/spl-token": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@solana/spl-token/-/spl-token-0.4.14.tgz", + "integrity": "sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA==", + "license": "Apache-2.0", + "dependencies": { + "@solana/buffer-layout": "^4.0.0", + "@solana/buffer-layout-utils": "^0.2.0", + "@solana/spl-token-group": "^0.0.7", + "@solana/spl-token-metadata": "^0.1.6", + "buffer": "^6.0.3" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.5" + } + }, + "node_modules/@solana/spl-token-group": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@solana/spl-token-group/-/spl-token-group-0.0.7.tgz", + "integrity": "sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug==", + "license": "Apache-2.0", + "dependencies": { + "@solana/codecs": "2.0.0-rc.1" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.3" + } + }, + "node_modules/@solana/spl-token-metadata": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@solana/spl-token-metadata/-/spl-token-metadata-0.1.6.tgz", + "integrity": "sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==", + "license": "Apache-2.0", + "dependencies": { + "@solana/codecs": "2.0.0-rc.1" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.3" + } + }, "node_modules/@solana/wallet-standard-chains": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@solana/wallet-standard-chains/-/wallet-standard-chains-1.1.1.tgz", @@ -5717,6 +6045,37 @@ "node": ">=0.6" } }, + "node_modules/bigint-buffer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bigint-buffer/-/bigint-buffer-1.1.5.tgz", + "integrity": "sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bindings": "^1.3.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bn.js": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", @@ -8223,6 +8582,13 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastestsmallesttextencoderdecoder": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", + "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", + "license": "CC0-1.0", + "peer": true + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8281,6 +8647,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/mobile_app/package.json b/mobile_app/package.json index 6064eddf..0be9a430 100644 --- a/mobile_app/package.json +++ b/mobile_app/package.json @@ -26,6 +26,7 @@ "@react-navigation/native": "^7.1.8", "@solana-mobile/mobile-wallet-adapter-protocol": "^2.2.8", "@solana-mobile/mobile-wallet-adapter-protocol-web3js": "^2.2.8", + "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.4", "@types/three": "^0.184.0", "bs58": "^6.0.0", diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 6c0f882d..3dca4629 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -6,6 +6,11 @@ import { SystemProgram, Transaction, } from "@solana/web3.js"; +import { + createAssociatedTokenAccountInstruction, + createTransferInstruction, + getAssociatedTokenAddress, +} from "@solana/spl-token"; import { transact } from "@solana-mobile/mobile-wallet-adapter-protocol-web3js"; import { Buffer } from "buffer"; @@ -37,12 +42,29 @@ export interface SendSolParams { amountSOL: number; } +export interface SendSplParams { + walletAdapter: IWalletAdapter; + rpcAdapter: IRpcAdapter; + recipientAddress: string; + amount: string; + mintAddress: string; + decimals: number; +} + export interface EstimateSolTransferFeeParams { walletAdapter: IWalletAdapter; recipientAddress: string; amountSOL: number; } +export interface EstimateSplTransferFeeParams { + walletAdapter: IWalletAdapter; + recipientAddress: string; + amount: string; + mintAddress: string; + decimals: number; +} + export interface SendResult { signature: string; explorerUrl: string; @@ -114,63 +136,87 @@ function buildSolTransferTransaction({ ); } -export async function estimateSolTransferFeeLamports({ - walletAdapter, - recipientAddress, - amountSOL, -}: EstimateSolTransferFeeParams): Promise { - const fromPubkey = walletAdapter.getPublicKey(); - if (!fromPubkey) { - throw new Error("Wallet not connected"); +function parseTokenUnits(amount: string, decimals: number): bigint { + const normalized = amount.trim(); + if (!/^\d+(\.\d+)?$/.test(normalized)) { + throw new Error("Invalid amount"); } - const tx = buildSolTransferTransaction({ fromPubkey, recipientAddress, amountSOL }); - const { blockhash } = await solanaConnection.getLatestBlockhash("confirmed"); - tx.recentBlockhash = blockhash; - tx.feePayer = fromPubkey; + const [whole, fraction = ""] = normalized.split("."); + if (fraction.length > decimals) { + throw new Error(`Too many decimal places for this token`); + } - // Fee estimate stays direct-RPC until IRpcAdapter exposes getFeeForMessage. - const fee = await solanaConnection.getFeeForMessage(tx.compileMessage(), "confirmed"); - if (fee.value === null) { - throw new Error("Fee unavailable"); + const units = `${whole}${fraction.padEnd(decimals, "0")}`.replace(/^0+(?=\d)/, ""); + const value = BigInt(units || "0"); + if (value <= 0n) { + throw new Error("Invalid amount"); } - return fee.value; + return value; } -/** - * Sign + submit a SOL transfer on devnet. - * - * Local wallet mode → exports secret key via biometric-gated path, - * signs in-app, submits via the selected RPC adapter. Secret is - * zeroed out of memory immediately after signing. - * - * MWA mode → reauthorizes or refreshes authorization, asks Seed Vault - * to sign, then submits via the selected RPC adapter. - * - * SOL-only for now. USDC / SPL token transfers need associated - * token account handling which lands with the Jupiter integration. - */ -export async function sendSolTransfer({ - walletAdapter, - rpcAdapter, +async function buildSplTransferTransaction({ + fromPubkey, recipientAddress, - amountSOL, -}: SendSolParams): Promise { - const fromPubkey = walletAdapter.getPublicKey(); - if (!fromPubkey) { - throw new Error("Wallet not connected"); + amount, + mintAddress, + decimals, +}: { + fromPubkey: PublicKey; + recipientAddress: string; + amount: string; + mintAddress: string; + decimals: number; +}): Promise { + let toOwner: PublicKey; + let mint: PublicKey; + try { + toOwner = new PublicKey(recipientAddress); + mint = new PublicKey(mintAddress); + } catch { + throw new Error("Invalid recipient or token mint"); } - const tx = buildSolTransferTransaction({ fromPubkey, recipientAddress, amountSOL }); + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error("Invalid token decimals"); + } + + const rawAmount = parseTokenUnits(amount, decimals); + const fromAta = await getAssociatedTokenAddress(mint, fromPubkey); + const toAta = await getAssociatedTokenAddress(mint, toOwner); + + const tx = new Transaction(); + + // ATA existence is still a direct Solana RPC read because IRpcAdapter only + // exposes balance/blockhash/submission. Submission itself uses the selected + // network adapter, so mesh relay still carries the signed transaction. + const toAtaInfo = await solanaConnection.getAccountInfo(toAta, "confirmed"); + if (!toAtaInfo) { + tx.add(createAssociatedTokenAccountInstruction(fromPubkey, toAta, toOwner, mint)); + } + + tx.add(createTransferInstruction(fromAta, toAta, fromPubkey, rawAmount)); + return tx; +} + +async function signAndSubmitTransaction({ + walletAdapter, + rpcAdapter, + tx, + expectedPubkey, +}: { + walletAdapter: IWalletAdapter; + rpcAdapter: IRpcAdapter; + tx: Transaction; + expectedPubkey: PublicKey; +}): Promise { const { blockhash } = await rpcAdapter.getLatestBlockhash(); tx.recentBlockhash = blockhash; - tx.feePayer = fromPubkey; + tx.feePayer = expectedPubkey; const mode = walletAdapter.getMode(); if (mode === "local") { - // Local wallet: secret key is only decrypted long enough to sign, - // then zeroed. Biometric prompt fires inside exportSecretKey. let secretKey: Uint8Array; try { secretKey = await walletAdapter.exportSecretKey(); @@ -188,23 +234,21 @@ export async function sendSolTransfer({ return { signature, explorerUrl: explorerUrl(signature) }; } - // MWA mode — Seeker / Saga Seed Vault flow. Try cached-token - // reauthorization first, then fall back to full authorize when the - // cached token/session is stale. The vault signs only; the app submits - // via its selected RPC adapter. const cachedToken = await secureGet(SecureKeys.MWA_TOKEN); - const signedTransactions: Transaction[] = []; await transact(async (mwaWallet) => { const auth = await mwaWallet.reauthorize({ - auth_token: cachedToken, + auth_token: cachedToken ?? "", identity: APP_IDENTITY, }); + const nextToken = (auth as MwaAuthResult).auth_token; + if (nextToken) await secureSet(SecureKeys.MWA_TOKEN, nextToken); + const sessionPubkey = new PublicKey(Buffer.from(auth.accounts[0].address, "base64")); - if (sessionPubkey.toBase58() !== fromPubkey.toBase58()) { + if (sessionPubkey.toBase58() !== expectedPubkey.toBase58()) { throw new Error( - `MWA account mismatch — expected ${fromPubkey.toBase58().slice(0, 8)}…, wallet returned ${sessionPubkey.toBase58().slice(0, 8)}…. Reconnect the correct account.`, + `MWA account mismatch — expected ${expectedPubkey.toBase58().slice(0, 8)}…, wallet returned ${sessionPubkey.toBase58().slice(0, 8)}…. Reconnect the correct account.`, ); } @@ -221,3 +265,108 @@ export async function sendSolTransfer({ const signature = await rpcAdapter.sendRawTransaction(signedTx.serialize()); return { signature, explorerUrl: explorerUrl(signature) }; } + +export async function estimateSolTransferFeeLamports({ + walletAdapter, + recipientAddress, + amountSOL, +}: EstimateSolTransferFeeParams): Promise { + const fromPubkey = walletAdapter.getPublicKey(); + if (!fromPubkey) { + throw new Error("Wallet not connected"); + } + + const tx = buildSolTransferTransaction({ fromPubkey, recipientAddress, amountSOL }); + const { blockhash } = await solanaConnection.getLatestBlockhash("confirmed"); + tx.recentBlockhash = blockhash; + tx.feePayer = fromPubkey; + + // Fee estimate stays direct-RPC until IRpcAdapter exposes getFeeForMessage. + const fee = await solanaConnection.getFeeForMessage(tx.compileMessage(), "confirmed"); + if (fee.value === null) { + throw new Error("Fee unavailable"); + } + return fee.value; +} + +export async function estimateSplTransferFeeLamports({ + walletAdapter, + recipientAddress, + amount, + mintAddress, + decimals, +}: EstimateSplTransferFeeParams): Promise { + const fromPubkey = walletAdapter.getPublicKey(); + if (!fromPubkey) { + throw new Error("Wallet not connected"); + } + + const tx = await buildSplTransferTransaction({ + fromPubkey, + recipientAddress, + amount, + mintAddress, + decimals, + }); + const { blockhash } = await solanaConnection.getLatestBlockhash("confirmed"); + tx.recentBlockhash = blockhash; + tx.feePayer = fromPubkey; + + // Fee estimate stays direct-RPC until IRpcAdapter exposes getFeeForMessage. + const fee = await solanaConnection.getFeeForMessage(tx.compileMessage(), "confirmed"); + if (fee.value === null) { + throw new Error("Fee unavailable"); + } + return fee.value; +} + +/** + * Sign + submit a SOL transfer on devnet. + * + * Local wallet mode → exports secret key via biometric-gated path, + * signs in-app, submits via the selected RPC adapter. Secret is + * zeroed out of memory immediately after signing. + * + * MWA mode → reauthorizes or refreshes authorization, asks Seed Vault + * to sign, then submits via the selected RPC adapter. + * + * SOL-only for now. USDC / SPL token transfers need associated + * token account handling which lands with the Jupiter integration. + */ +export async function sendSolTransfer({ + walletAdapter, + rpcAdapter, + recipientAddress, + amountSOL, +}: SendSolParams): Promise { + const fromPubkey = walletAdapter.getPublicKey(); + if (!fromPubkey) { + throw new Error("Wallet not connected"); + } + + const tx = buildSolTransferTransaction({ fromPubkey, recipientAddress, amountSOL }); + return signAndSubmitTransaction({ walletAdapter, rpcAdapter, tx, expectedPubkey: fromPubkey }); +} + +export async function sendSplTransfer({ + walletAdapter, + rpcAdapter, + recipientAddress, + amount, + mintAddress, + decimals, +}: SendSplParams): Promise { + const fromPubkey = walletAdapter.getPublicKey(); + if (!fromPubkey) { + throw new Error("Wallet not connected"); + } + + const tx = await buildSplTransferTransaction({ + fromPubkey, + recipientAddress, + amount, + mintAddress, + decimals, + }); + return signAndSubmitTransaction({ walletAdapter, rpcAdapter, tx, expectedPubkey: fromPubkey }); +} diff --git a/mobile_app/src/services/walletData.ts b/mobile_app/src/services/walletData.ts index 728d7474..ba69ac33 100644 --- a/mobile_app/src/services/walletData.ts +++ b/mobile_app/src/services/walletData.ts @@ -101,11 +101,16 @@ export interface ActivityEntry { signature: string; direction: ActivityDirection; status: ActivityStatus; - amountLamports: number; + amountBaseUnits: string; + amountLamports?: number; amountSol: number; - symbol: "SOL"; + symbol: string; + mintAddress?: string; counterparty: string; createdAt: number; + feeLamports: number | null; + memo: string | null; + slot: number | null; } interface TransferInfo { @@ -114,6 +119,12 @@ interface TransferInfo { lamports: number; } +interface SplTransferInfo { + source: string; + destination: string; + mint: string; +} + function extractSystemTransfer( instruction: ParsedInstruction | PartiallyDecodedInstruction, walletAddress: string, @@ -141,6 +152,80 @@ function extractSystemTransfer( return { source, destination, lamports }; } +function extractSplTransfer( + instruction: ParsedInstruction | PartiallyDecodedInstruction, +): SplTransferInfo | null { + if (!("parsed" in instruction)) return null; + if (instruction.program !== "spl-token" && instruction.program !== "spl-token-2022") return null; + const parsed = instruction.parsed; + if (!parsed || typeof parsed !== "object") return null; + if (!("type" in parsed) || (parsed.type !== "transfer" && parsed.type !== "transferChecked")) return null; + if (!("info" in parsed) || typeof parsed.info !== "object" || parsed.info === null) return null; + + const info = parsed.info as { source?: unknown; destination?: unknown; mint?: unknown }; + const source = typeof info.source === "string" ? info.source : null; + const destination = typeof info.destination === "string" ? info.destination : null; + const mint = typeof info.mint === "string" ? info.mint : null; + + if (!source || !destination || !mint) return null; + return { source, destination, mint }; +} + +function extractMemo(parsedTx: ParsedTransactionWithMeta): string | null { + for (const instruction of parsedTx.transaction.message.instructions) { + if (!("parsed" in instruction)) continue; + if (instruction.program !== "spl-memo") continue; + const parsed = instruction.parsed; + if (typeof parsed === "string") return parsed; + if (parsed && typeof parsed === "object" && "memo" in parsed && typeof parsed.memo === "string") { + return parsed.memo; + } + } + return null; +} + +function tokenAmount(raw: unknown): bigint { + if (typeof raw === "string" && /^\d+$/.test(raw)) return BigInt(raw); + if (typeof raw === "number" && Number.isFinite(raw)) return BigInt(Math.trunc(raw)); + return 0n; +} + +function walletTokenDelta( + walletAddress: string, + parsedTx: ParsedTransactionWithMeta, +): { + amountBaseUnits: bigint; + decimals: number; + mint: string; + symbol: string; + tokenAccountIndex: number; +} | null { + const pre = new Map(); + for (const bal of parsedTx.meta?.preTokenBalances ?? []) { + if (bal.owner !== walletAddress) continue; + pre.set(`${bal.accountIndex}:${bal.mint}`, tokenAmount(bal.uiTokenAmount.amount)); + } + + for (const bal of parsedTx.meta?.postTokenBalances ?? []) { + if (bal.owner !== walletAddress) continue; + const key = `${bal.accountIndex}:${bal.mint}`; + const before = pre.get(key) ?? 0n; + const after = tokenAmount(bal.uiTokenAmount.amount); + const delta = after - before; + if (delta === 0n) continue; + const resolved = resolveMint(bal.mint); + return { + amountBaseUnits: delta, + decimals: bal.uiTokenAmount.decimals, + mint: bal.mint, + symbol: resolved.symbol, + tokenAccountIndex: bal.accountIndex, + }; + } + + return null; +} + function toActivity( walletAddress: string, signature: string, @@ -150,11 +235,45 @@ function toActivity( if (!parsedTx?.meta) return null; const failed = parsedTx.meta.err !== null; + const memo = extractMemo(parsedTx); const transfer = parsedTx.transaction.message.instructions .map((ix) => extractSystemTransfer(ix, walletAddress)) .find(Boolean); - if (!transfer) return null; + if (!transfer) { + const tokenDelta = walletTokenDelta(walletAddress, parsedTx); + if (!tokenDelta) return null; + + const keys = parsedTx.transaction.message.accountKeys; + const walletTokenAccount = keys[tokenDelta.tokenAccountIndex]?.pubkey.toBase58(); + const splTransfer = parsedTx.transaction.message.instructions + .map(extractSplTransfer) + .find((ix) => ix?.mint === tokenDelta.mint && (ix.source === walletTokenAccount || ix.destination === walletTokenAccount)); + + const direction: ActivityDirection = tokenDelta.amountBaseUnits < 0n ? "send" : "receive"; + const counterparty = + direction === "send" + ? splTransfer?.destination ?? tokenDelta.mint + : splTransfer?.source ?? tokenDelta.mint; + const amountAbs = tokenDelta.amountBaseUnits < 0n ? -tokenDelta.amountBaseUnits : tokenDelta.amountBaseUnits; + const createdAt = blockTime ? blockTime * 1000 : Date.now(); + + return { + id: signature, + signature, + direction, + status: failed ? "Failed" : "Settled", + amountBaseUnits: amountAbs.toString(), + amountSol: Number(amountAbs) / Math.pow(10, tokenDelta.decimals), + symbol: tokenDelta.symbol, + mintAddress: tokenDelta.mint, + counterparty, + createdAt, + feeLamports: parsedTx.meta.fee ?? null, + memo, + slot: parsedTx.slot ?? null, + }; + } const direction: ActivityDirection = transfer.source === walletAddress ? "send" : "receive"; const counterparty = direction === "send" ? transfer.destination : transfer.source; @@ -165,11 +284,15 @@ function toActivity( signature, direction, status: failed ? "Failed" : "Settled", + amountBaseUnits: String(transfer.lamports), amountLamports: transfer.lamports, amountSol: transfer.lamports / LAMPORTS_PER_SOL, symbol: "SOL", counterparty, createdAt, + feeLamports: parsedTx.meta.fee ?? null, + memo, + slot: parsedTx.slot ?? null, }; } From 66647e71767b4dafd01886da8a086b10af20b29d Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:07:27 -0800 Subject: [PATCH 03/30] feat(settings): gate wallet recovery key --- .../components/settings/ExportWalletModal.tsx | 44 ++++++++++++++++--- mobile_app/package-lock.json | 12 ++++- mobile_app/package.json | 1 + mobile_app/screens/SettingsScreen.tsx | 2 +- 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/mobile_app/components/settings/ExportWalletModal.tsx b/mobile_app/components/settings/ExportWalletModal.tsx index b86c382e..9e482b37 100644 --- a/mobile_app/components/settings/ExportWalletModal.tsx +++ b/mobile_app/components/settings/ExportWalletModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { View, Text, Pressable, Modal, StyleSheet, Animated } from 'react-native'; import { Feather } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; +import * as ScreenCapture from 'expo-screen-capture'; import { fontFamily, useTheme } from '@/theme'; import { useGlass } from '@/hooks/useGlass'; import { useWallet } from '@/context/WalletContext'; @@ -17,15 +18,24 @@ export function ExportWalletModal({ onClose }: { onClose: () => void }) { const [loading, setLoading] = useState(false); const [failed, setFailed] = useState(false); const [keyCopied, setKeyCopied] = useState(false); + const [copiedAck, setCopiedAck] = useState(false); const sheetAnim = useRef(new Animated.Value(0)).current; useEffect(() => { Animated.spring(sheetAnim, { toValue: 1, useNativeDriver: true, bounciness: 4 }).start(); }, [sheetAnim]); + useEffect(() => { + ScreenCapture.preventScreenCaptureAsync().catch(() => undefined); + return () => { + ScreenCapture.allowScreenCaptureAsync().catch(() => undefined); + }; + }, []); + const authenticate = useCallback(async () => { setLoading(true); setFailed(false); + setCopiedAck(false); const key = await exportPrivateKey(); if (key) { setSecretKey(key); } else { setFailed(true); } setLoading(false); @@ -39,6 +49,7 @@ export function ExportWalletModal({ onClose }: { onClose: () => void }) { }, [secretKey]); const dismiss = () => { + if (secretKey && !copiedAck) return; Animated.timing(sheetAnim, { toValue: 0, duration: 220, useNativeDriver: true }).start(onClose); }; @@ -60,7 +71,7 @@ export function ExportWalletModal({ onClose }: { onClose: () => void }) { EXPORT WALLET - {walletMode === 'mwa' ? 'not available' : 'secret key'} + {walletMode === 'mwa' ? 'not available' : 'recovery key'} @@ -88,7 +99,7 @@ export function ExportWalletModal({ onClose }: { onClose: () => void }) { - Never share this key. Store offline only. Anyone with this key controls your wallet. + No mnemonic exists for this wallet. This base58 recovery key controls the wallet; store it offline only. @@ -106,13 +117,30 @@ export function ExportWalletModal({ onClose }: { onClose: () => void }) { /> {!!secretKey && ( - - HOLD TO REVEAL · BASE58 ENCODED - + <> + + HOLD TO REVEAL · SCREENSHOTS BLOCKED · BASE58 ENCODED + + setCopiedAck(v => !v)} + style={[S.ackRow, { borderColor: copiedAck ? colors.primary + '66' : colors.border, backgroundColor: colors.surface1 }]} + > + + + I copied this recovery key + + + )} - - DONE + + DONE )} @@ -136,6 +164,8 @@ const S = StyleSheet.create({ warn: { flexDirection: 'row', gap: 10, padding: 12, borderRadius: 12, borderWidth: 0.5 }, warnText: { flex: 1, fontFamily: fontFamily.sansMd, fontSize: 11, lineHeight: 17, letterSpacing: 0.2 }, hint: { fontFamily: fontFamily.sansMd, fontSize: 10, letterSpacing: 1 }, + ackRow: { alignItems: 'center', borderRadius: 12, borderWidth: 0.5, flexDirection: 'row', gap: 8, justifyContent: 'center', padding: 12 }, + ackText: { fontFamily: fontFamily.sansMd, fontSize: 11, letterSpacing: 0.8, textTransform: 'uppercase' }, doneBtn: { width: '100%', padding: 13, borderRadius: 12, alignItems: 'center', marginTop: 4 }, doneBtnText: { fontFamily: fontFamily.sansMd, fontSize: 11, fontWeight: '600', letterSpacing: 3, textTransform: 'uppercase' }, }); diff --git a/mobile_app/package-lock.json b/mobile_app/package-lock.json index 21eeb826..f32c1d2e 100644 --- a/mobile_app/package-lock.json +++ b/mobile_app/package-lock.json @@ -45,6 +45,7 @@ "expo-navigation-bar": "~5.0.10", "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", + "expo-screen-capture": "~8.0.9", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", @@ -1521,7 +1522,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -8426,6 +8426,16 @@ "node": ">=10" } }, + "node_modules/expo-screen-capture": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/expo-screen-capture/-/expo-screen-capture-8.0.9.tgz", + "integrity": "sha512-Xu3ZHlqxO2rEa/R5BOSyTBIJzgVA4sxzMf9jz03dSg5X/I9qwD984cSH9dABJY8NYLQmxKYUTN8jTbA9jzgcTw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-secure-store": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", diff --git a/mobile_app/package.json b/mobile_app/package.json index 0be9a430..25edbf32 100644 --- a/mobile_app/package.json +++ b/mobile_app/package.json @@ -48,6 +48,7 @@ "expo-navigation-bar": "~5.0.10", "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", + "expo-screen-capture": "~8.0.9", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", diff --git a/mobile_app/screens/SettingsScreen.tsx b/mobile_app/screens/SettingsScreen.tsx index 502a695c..2ceb570e 100644 --- a/mobile_app/screens/SettingsScreen.tsx +++ b/mobile_app/screens/SettingsScreen.tsx @@ -231,7 +231,7 @@ export default function SettingsScreen() { { if (v) setBiometric(true); else setDisableBioOpen(true); }} />} /> } onPress={() => setRotateOpen(true)} /> - } onPress={() => setExportOpen(true)} last /> + } onPress={() => setExportOpen(true)} last /> From 5ad45aa4ccff57bc6811f49ac40b989fbe254e44 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:09:55 -0800 Subject: [PATCH 04/30] chore(wallet): clarify transfer comments --- mobile_app/src/services/sendTransaction.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 3dca4629..74f5373b 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -330,8 +330,7 @@ export async function estimateSplTransferFeeLamports({ * MWA mode → reauthorizes or refreshes authorization, asks Seed Vault * to sign, then submits via the selected RPC adapter. * - * SOL-only for now. USDC / SPL token transfers need associated - * token account handling which lands with the Jupiter integration. + * SPL token transfers share the same signer/submission path below. */ export async function sendSolTransfer({ walletAdapter, From 3436f986360d37b7398c543ebdea77c2e2a44f5c Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:12:47 -0800 Subject: [PATCH 05/30] chore(expo): align sdk validation --- mobile_app/app.json | 14 +- mobile_app/assets/images/favicon.png | Bin 12028 -> 53974 bytes mobile_app/assets/images/icon.png | Bin 12028 -> 53974 bytes mobile_app/package-lock.json | 659 +++++++++++++-------------- mobile_app/package.json | 18 +- 5 files changed, 349 insertions(+), 342 deletions(-) diff --git a/mobile_app/app.json b/mobile_app/app.json index 18764105..60a422d5 100644 --- a/mobile_app/app.json +++ b/mobile_app/app.json @@ -15,8 +15,15 @@ "NSCameraUsageDescription": "This app requires camera access to scan QR codes for connecting with peers and sharing content.", "NSPhotoLibraryUsageDescription": "Allow anonmesh to access your photo library to send images in encrypted chats.", "NSFaceIDUsageDescription": "This app uses Face ID to securely authenticate you when accessing sensitive features or information within the app.", - "UIBackgroundModes": ["bluetooth-central", "bluetooth-peripheral", "fetch", "processing"], - "BGTaskSchedulerPermittedIdentifiers": ["magicred1.anonmesh.app.background-processing"] + "UIBackgroundModes": [ + "bluetooth-central", + "bluetooth-peripheral", + "fetch", + "processing" + ], + "BGTaskSchedulerPermittedIdentifiers": [ + "magicred1.anonmesh.app.background-processing" + ] }, "entitlements": { "aps-environment": "production" @@ -88,7 +95,8 @@ } ], "@magicred-1/react-native-lxmf", - "./plugins/withAndroidForegroundService" + "./plugins/withAndroidForegroundService", + "expo-web-browser" ], "experiments": { "typedRoutes": true, diff --git a/mobile_app/assets/images/favicon.png b/mobile_app/assets/images/favicon.png index ec152e35947ccef5eb7bb8e39dc69bbfa4896f46..bd5aabbb6d192180733307e925931e5bcc757b02 100644 GIT binary patch literal 53974 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 literal 12028 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 diff --git a/mobile_app/assets/images/icon.png b/mobile_app/assets/images/icon.png index ec152e35947ccef5eb7bb8e39dc69bbfa4896f46..bd5aabbb6d192180733307e925931e5bcc757b02 100644 GIT binary patch literal 53974 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 literal 12028 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 diff --git a/mobile_app/package-lock.json b/mobile_app/package-lock.json index f32c1d2e..f0a54254 100644 --- a/mobile_app/package-lock.json +++ b/mobile_app/package-lock.json @@ -17,7 +17,7 @@ "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@react-native-async-storage/async-storage": "2.2.0", - "@react-native-community/netinfo": "^12.0.1", + "@react-native-community/netinfo": "11.4.1", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -28,22 +28,22 @@ "@types/three": "^0.184.0", "bs58": "^6.0.0", "buffer": "^6.0.3", - "expo": "~54.0.33", + "expo": "~54.0.34", "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", - "expo-crypto": "~15.0.8", - "expo-dev-client": "~6.0.20", + "expo-crypto": "~15.0.9", + "expo-dev-client": "~6.0.21", "expo-font": "~14.0.11", "expo-gl": "~16.0.10", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-image-picker": "~17.0.11", "expo-linear-gradient": "~15.0.8", - "expo-linking": "~8.0.11", - "expo-local-authentication": "^55.0.13", + "expo-linking": "~8.0.12", + "expo-local-authentication": "~17.0.8", "expo-navigation-bar": "~5.0.10", - "expo-notifications": "~0.32.16", + "expo-notifications": "~0.32.17", "expo-router": "~6.0.23", "expo-screen-capture": "~8.0.9", "expo-secure-store": "~15.0.8", @@ -51,12 +51,12 @@ "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", - "expo-web-browser": "~15.0.10", + "expo-web-browser": "~15.0.11", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", - "react-native-get-random-values": "^2.0.0", + "react-native-get-random-values": "~1.11.0", "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", @@ -1792,198 +1792,6 @@ "integrity": "sha512-ZVQYw4Ok/pgcSJiufP8oRZE3AVxS9xtmKEUfsurbHkHNdMc/GA1gDXP9G4Cr7KL4KqSc0haexR2TuMigotCn4Q==", "license": "MIT AND OFL-1.1" }, - "node_modules/@expo/cli": { - "version": "54.0.23", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", - "integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==", - "license": "MIT", - "dependencies": { - "@0no-co/graphql.web": "^1.0.8", - "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~12.0.13", - "@expo/config-plugins": "~54.0.4", - "@expo/devcert": "^1.2.1", - "@expo/env": "~2.0.8", - "@expo/image-utils": "^0.8.8", - "@expo/json-file": "^10.0.8", - "@expo/metro": "~54.2.0", - "@expo/metro-config": "~54.0.14", - "@expo/osascript": "^2.3.8", - "@expo/package-manager": "^1.9.10", - "@expo/plist": "^0.4.8", - "@expo/prebuild-config": "^54.0.8", - "@expo/schema-utils": "^0.1.8", - "@expo/spawn-async": "^1.7.2", - "@expo/ws-tunnel": "^1.0.1", - "@expo/xcpretty": "^4.3.0", - "@react-native/dev-middleware": "0.81.5", - "@urql/core": "^5.0.6", - "@urql/exchange-retry": "^1.3.0", - "accepts": "^1.3.8", - "arg": "^5.0.2", - "better-opn": "~3.0.2", - "bplist-creator": "0.1.0", - "bplist-parser": "^0.3.1", - "chalk": "^4.0.0", - "ci-info": "^3.3.0", - "compression": "^1.7.4", - "connect": "^3.7.0", - "debug": "^4.3.4", - "env-editor": "^0.4.1", - "expo-server": "^1.0.5", - "freeport-async": "^2.0.0", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "lan-network": "^0.1.6", - "minimatch": "^9.0.0", - "node-forge": "^1.3.3", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "picomatch": "^3.0.1", - "pretty-bytes": "^5.6.0", - "pretty-format": "^29.7.0", - "progress": "^2.0.3", - "prompts": "^2.3.2", - "qrcode-terminal": "0.11.0", - "require-from-string": "^2.0.2", - "requireg": "^0.2.2", - "resolve": "^1.22.2", - "resolve-from": "^5.0.0", - "resolve.exports": "^2.0.3", - "semver": "^7.6.0", - "send": "^0.19.0", - "slugify": "^1.3.4", - "source-map-support": "~0.5.21", - "stacktrace-parser": "^0.1.10", - "structured-headers": "^0.4.1", - "tar": "^7.5.2", - "terminal-link": "^2.1.1", - "undici": "^6.18.2", - "wrap-ansi": "^7.0.0", - "ws": "^8.12.1" - }, - "bin": { - "expo-internal": "build/bin/cli" - }, - "peerDependencies": { - "expo": "*", - "expo-router": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "expo-router": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/@expo/cli/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@expo/cli/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/cli/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@expo/cli/node_modules/picomatch": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.2.tgz", - "integrity": "sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@expo/cli/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@expo/cli/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@expo/cli/node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -2129,9 +1937,9 @@ } }, "node_modules/@expo/fingerprint": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz", - "integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==", + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz", + "integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -2141,7 +1949,7 @@ "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", - "minimatch": "^9.0.0", + "minimatch": "^10.2.2", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" @@ -2150,25 +1958,37 @@ "fingerprint": "bin/cli.js" } }, + "node_modules/@expo/fingerprint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@expo/fingerprint/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@expo/fingerprint/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2214,9 +2034,9 @@ } }, "node_modules/@expo/json-file": { - "version": "10.0.13", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.13.tgz", - "integrity": "sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==", + "version": "10.0.14", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.14.tgz", + "integrity": "sha512-yWwBFywFv+SxkJp/pIzzA416JVYflNUh7pqQzgaA6nXDqRyK7KfrqVzk8PdUfDnqbBcaZZxpzNssfQZzp5KHrA==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -2246,9 +2066,9 @@ } }, "node_modules/@expo/metro-config": { - "version": "54.0.14", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz", - "integrity": "sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==", + "version": "54.0.15", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.15.tgz", + "integrity": "sha512-SqIya4VZ9KHM1S9g+xR0A+QKw1Tfs7Gacx6bQNJ98vs4+O7I5+QP5mHZIB0QSZLUV8opiXebHYTiTu+0OAsIUw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -2269,7 +2089,7 @@ "hermes-parser": "^0.29.1", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", - "minimatch": "^9.0.0", + "picomatch": "^4.0.3", "postcss": "~8.4.32", "resolve-from": "^5.0.0" }, @@ -2282,28 +2102,16 @@ } } }, - "node_modules/@expo/metro-config/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "node_modules/@expo/metro-config/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@expo/metro-config/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/@expo/metro-runtime": { @@ -2330,9 +2138,9 @@ } }, "node_modules/@expo/osascript": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.2.tgz", - "integrity": "sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.3.tgz", + "integrity": "sha512-wbuj3EebM7W9hN/Wp4xTzKd6rQ2zKJzAxkFxkOOwyysLp0HOAgQ4/5RINyoS241pZUX2rUHq7mAJ7pcCQ8U0Ow==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2" @@ -2342,12 +2150,12 @@ } }, "node_modules/@expo/package-manager": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.4.tgz", - "integrity": "sha512-y9Mr4Kmpk4abAVZrNNPCdzOZr8nLLyi18p1SXr0RCVA8IfzqZX/eY4H+50a0HTmXqIsPZrQdcdb4I3ekMS9GvQ==", + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.5.tgz", + "integrity": "sha512-nCP9Mebfl3jvOr0/P6VAuyah6PAtun+aihIL2zAtuE8uSe94JWkVZ7051i0MUVO+y3gFpBqnr8IIH5ch+VJjHA==", "license": "MIT", "dependencies": { - "@expo/json-file": "^10.0.13", + "@expo/json-file": "^10.0.14", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", @@ -2466,9 +2274,9 @@ "license": "MIT" }, "node_modules/@expo/xcpretty": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.3.tgz", - "integrity": "sha512-wC562eD3gS6vO2tWHToFhlFnmHKfKHgF1oyvojeSkLK/ZYop1bMU+7cOMiF9Sq70CzcsLy/EMRy/uRc76QmNRw==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", + "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", "license": "BSD-3-Clause", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -3372,12 +3180,11 @@ } }, "node_modules/@react-native-community/netinfo": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz", - "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz", + "integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==", "license": "MIT", "peerDependencies": { - "react": "*", "react-native": ">=0.59" } }, @@ -7733,30 +7540,30 @@ "license": "MIT" }, "node_modules/expo": { - "version": "54.0.33", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", - "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", + "version": "54.0.34", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.34.tgz", + "integrity": "sha512-XkVHguZZDC8BcTQxHAd14/TQFbDp1Wt0Z/KApO9t68Ll5A127hLCPzU+a9gytfCIiyL/V1IpF1vIcOLKEVAoNQ==", "license": "MIT", "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.23", + "@expo/cli": "54.0.24", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devtools": "0.1.8", - "@expo/fingerprint": "0.15.4", + "@expo/fingerprint": "0.15.5", "@expo/metro": "~54.2.0", - "@expo/metro-config": "54.0.14", + "@expo/metro-config": "54.0.15", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.10", - "expo-asset": "~12.0.12", + "expo-asset": "~12.0.13", "expo-constants": "~18.0.13", - "expo-file-system": "~19.0.21", + "expo-file-system": "~19.0.22", "expo-font": "~14.0.11", "expo-keep-awake": "~15.0.8", - "expo-modules-autolinking": "3.0.24", - "expo-modules-core": "3.0.29", + "expo-modules-autolinking": "3.0.25", + "expo-modules-core": "3.0.30", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" @@ -7795,13 +7602,13 @@ } }, "node_modules/expo-asset": { - "version": "12.0.12", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", - "integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==", + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", + "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.8.8", - "expo-constants": "~18.0.12" + "expo-constants": "~18.0.13" }, "peerDependencies": { "expo": "*", @@ -7856,9 +7663,9 @@ } }, "node_modules/expo-crypto": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz", - "integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==", + "version": "15.0.9", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz", + "integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==", "license": "MIT", "dependencies": { "base64-js": "^1.3.0" @@ -7868,15 +7675,15 @@ } }, "node_modules/expo-dev-client": { - "version": "6.0.20", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.20.tgz", - "integrity": "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==", + "version": "6.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.21.tgz", + "integrity": "sha512-SWI6HD0pa4eJujkYFkvvpezUE1zmJXGLu+34azpu7+QJgO+FLutDYDj8BSTdeH/NYDEClDFjCGqVMcWETvmsCQ==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "6.0.20", - "expo-dev-menu": "7.0.18", + "expo-dev-launcher": "6.0.21", + "expo-dev-menu": "7.0.19", "expo-dev-menu-interface": "2.0.0", - "expo-manifests": "~1.0.10", + "expo-manifests": "~1.0.11", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { @@ -7884,23 +7691,23 @@ } }, "node_modules/expo-dev-launcher": { - "version": "6.0.20", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.20.tgz", - "integrity": "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA==", + "version": "6.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.21.tgz", + "integrity": "sha512-QZ9gcKMZbp6EsIhzS0QoGB8Cf4xeVJhjbNgWUwcoBIk8gshoFz8CkCQOnX+HNv2sSY3rdCaNpx3Xo0Rflyq7rA==", "license": "MIT", "dependencies": { "ajv": "^8.11.0", - "expo-dev-menu": "7.0.18", - "expo-manifests": "~1.0.10" + "expo-dev-menu": "7.0.19", + "expo-manifests": "~1.0.11" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-launcher/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -7920,9 +7727,9 @@ "license": "MIT" }, "node_modules/expo-dev-menu": { - "version": "7.0.18", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.18.tgz", - "integrity": "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA==", + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.19.tgz", + "integrity": "sha512-ju5MZiBCPhUKKvHy0ElZdnlhq01mkEEiR8jfrgQVvW26aWjzjLiOhppNAyXtvGbhk7WxJim3wYMiqFFrjGdfKA==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "2.0.0" @@ -7941,9 +7748,9 @@ } }, "node_modules/expo-file-system": { - "version": "19.0.21", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", - "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", + "version": "19.0.22", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", + "integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -8068,13 +7875,13 @@ } }, "node_modules/expo-linking": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", - "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", + "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==", "license": "MIT", "peer": true, "dependencies": { - "expo-constants": "~18.0.12", + "expo-constants": "~18.0.13", "invariant": "^2.2.4" }, "peerDependencies": { @@ -8083,9 +7890,9 @@ } }, "node_modules/expo-local-authentication": { - "version": "55.0.13", - "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-55.0.13.tgz", - "integrity": "sha512-7m1+Roub/6xxjHtVKe0vVtsC5g0MXp1Nf0dxK0YfJ1i5314hvi/Glhl5/2loAgnplw8Nx4UVbf6L0/rDjhljsQ==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-17.0.8.tgz", + "integrity": "sha512-Q5fXHhu6w3pVPlFCibU72SYIAN+9wX7QpFn9h49IUqs0Equ44QgswtGrxeh7fdnDqJrrYGPet5iBzjnE70uolA==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" @@ -8095,12 +7902,12 @@ } }, "node_modules/expo-manifests": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz", - "integrity": "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz", + "integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.11", + "@expo/config": "~12.0.13", "expo-json-utils": "~0.15.0" }, "peerDependencies": { @@ -8108,9 +7915,9 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "3.0.24", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", - "integrity": "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==", + "version": "3.0.25", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", + "integrity": "sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -8124,9 +7931,9 @@ } }, "node_modules/expo-modules-core": { - "version": "3.0.29", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz", - "integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==", + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.30.tgz", + "integrity": "sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==", "license": "MIT", "peer": true, "dependencies": { @@ -8154,9 +7961,9 @@ } }, "node_modules/expo-notifications": { - "version": "0.32.16", - "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz", - "integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==", + "version": "0.32.17", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz", + "integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.8.8", @@ -8446,9 +8253,9 @@ } }, "node_modules/expo-server": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", - "integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz", + "integrity": "sha512-vb5TBtskvEdzYuW79lATXutOEBfW5m6U4EFpNjCVZTnI7S//SAsLQkYEpn+EDfn84m6VQfzSGkIVR6YPaScKFA==", "license": "MIT", "engines": { "node": ">=20.16.0" @@ -8522,15 +8329,207 @@ } }, "node_modules/expo-web-browser": { - "version": "15.0.10", - "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", - "integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==", + "version": "15.0.11", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz", + "integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==", "license": "MIT", "peerDependencies": { "expo": "*", "react-native": "*" } }, + "node_modules/expo/node_modules/@expo/cli": { + "version": "54.0.24", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz", + "integrity": "sha512-5xse1bEgnVUBhOrtttc6xTNJVvjyTRavpzuF0/0nuj+312vfSbk7EiRbG+xJ2pW/iZxnhLPJkFCrPYG0nmheAQ==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.8", + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.0.8", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "~54.0.15", + "@expo/osascript": "^2.3.8", + "@expo/package-manager": "^1.9.10", + "@expo/plist": "^0.4.8", + "@expo/prebuild-config": "^54.0.8", + "@expo/schema-utils": "^0.1.8", + "@expo/spawn-async": "^1.7.2", + "@expo/ws-tunnel": "^1.0.1", + "@expo/xcpretty": "^4.3.0", + "@react-native/dev-middleware": "0.81.5", + "@urql/core": "^5.0.6", + "@urql/exchange-retry": "^1.3.0", + "accepts": "^1.3.8", + "arg": "^5.0.2", + "better-opn": "~3.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "env-editor": "^0.4.1", + "expo-server": "^1.0.6", + "freeport-async": "^2.0.0", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "minimatch": "^9.0.0", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.3", + "pretty-bytes": "^5.6.0", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "qrcode-terminal": "0.11.0", + "require-from-string": "^2.0.2", + "requireg": "^0.2.2", + "resolve": "^1.22.2", + "resolve-from": "^5.0.0", + "resolve.exports": "^2.0.3", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "source-map-support": "~0.5.21", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "tar": "^7.5.2", + "terminal-link": "^2.1.1", + "undici": "^6.18.2", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1" + }, + "bin": { + "expo-internal": "build/bin/cli" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/expo/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/expo/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/expo/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/expo/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo/node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -8577,9 +8576,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -10369,9 +10368,9 @@ } }, "node_modules/lan-network": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz", - "integrity": "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", + "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", "license": "MIT", "bin": { "lan-network": "dist/lan-network-cli.js" @@ -12529,15 +12528,15 @@ } }, "node_modules/react-native-get-random-values": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz", - "integrity": "sha512-wx7/aPqsUIiWsG35D+MsUJd8ij96e3JKddklSdrdZUrheTx89gPtz3Q2yl9knBArj5u26Cl23T88ai+Q0vypdQ==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz", + "integrity": "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==", "license": "MIT", "dependencies": { "fast-base64-decode": "^1.0.0" }, "peerDependencies": { - "react-native": ">=0.81" + "react-native": ">=0.56" } }, "node_modules/react-native-is-edge-to-edge": { @@ -14063,9 +14062,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.14", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.14.tgz", + "integrity": "sha512-/7sHKgQO3JLP9ESlwTYUUftHUadOURUqq23xs1vjcnp8Vss6k0wCfzulyEtk5g91pjvnuriimGlyG7k6msrzRw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/mobile_app/package.json b/mobile_app/package.json index 25edbf32..4951435d 100644 --- a/mobile_app/package.json +++ b/mobile_app/package.json @@ -20,7 +20,7 @@ "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@react-native-async-storage/async-storage": "2.2.0", - "@react-native-community/netinfo": "^12.0.1", + "@react-native-community/netinfo": "11.4.1", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -31,22 +31,22 @@ "@types/three": "^0.184.0", "bs58": "^6.0.0", "buffer": "^6.0.3", - "expo": "~54.0.33", + "expo": "~54.0.34", "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", - "expo-crypto": "~15.0.8", - "expo-dev-client": "~6.0.20", + "expo-crypto": "~15.0.9", + "expo-dev-client": "~6.0.21", "expo-font": "~14.0.11", "expo-gl": "~16.0.10", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-image-picker": "~17.0.11", "expo-linear-gradient": "~15.0.8", - "expo-linking": "~8.0.11", - "expo-local-authentication": "^55.0.13", + "expo-linking": "~8.0.12", + "expo-local-authentication": "~17.0.8", "expo-navigation-bar": "~5.0.10", - "expo-notifications": "~0.32.16", + "expo-notifications": "~0.32.17", "expo-router": "~6.0.23", "expo-screen-capture": "~8.0.9", "expo-secure-store": "~15.0.8", @@ -54,12 +54,12 @@ "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", - "expo-web-browser": "~15.0.10", + "expo-web-browser": "~15.0.11", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", - "react-native-get-random-values": "^2.0.0", + "react-native-get-random-values": "~1.11.0", "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", From 687daac80a4c261dd5b6b1e3506081908f19a349 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:20:31 -0800 Subject: [PATCH 06/30] fix(wallet): resolve spl activity counterparties --- mobile_app/src/services/walletData.ts | 37 ++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/mobile_app/src/services/walletData.ts b/mobile_app/src/services/walletData.ts index ba69ac33..1d38360b 100644 --- a/mobile_app/src/services/walletData.ts +++ b/mobile_app/src/services/walletData.ts @@ -122,7 +122,7 @@ interface TransferInfo { interface SplTransferInfo { source: string; destination: string; - mint: string; + mint: string | null; } function extractSystemTransfer( @@ -167,7 +167,7 @@ function extractSplTransfer( const destination = typeof info.destination === "string" ? info.destination : null; const mint = typeof info.mint === "string" ? info.mint : null; - if (!source || !destination || !mint) return null; + if (!source || !destination) return null; return { source, destination, mint }; } @@ -198,8 +198,10 @@ function walletTokenDelta( decimals: number; mint: string; symbol: string; + tokenAccount: string; tokenAccountIndex: number; } | null { + const keys = parsedTx.transaction.message.accountKeys; const pre = new Map(); for (const bal of parsedTx.meta?.preTokenBalances ?? []) { if (bal.owner !== walletAddress) continue; @@ -214,11 +216,14 @@ function walletTokenDelta( const delta = after - before; if (delta === 0n) continue; const resolved = resolveMint(bal.mint); + const tokenAccount = keys[bal.accountIndex]?.pubkey.toBase58(); + if (!tokenAccount) continue; return { amountBaseUnits: delta, decimals: bal.uiTokenAmount.decimals, mint: bal.mint, symbol: resolved.symbol, + tokenAccount, tokenAccountIndex: bal.accountIndex, }; } @@ -226,6 +231,17 @@ function walletTokenDelta( return null; } +function tokenAccountOwners(parsedTx: ParsedTransactionWithMeta): Map { + const owners = new Map(); + const keys = parsedTx.transaction.message.accountKeys; + for (const bal of [...(parsedTx.meta?.preTokenBalances ?? []), ...(parsedTx.meta?.postTokenBalances ?? [])]) { + if (!bal.owner) continue; + const tokenAccount = keys[bal.accountIndex]?.pubkey.toBase58(); + if (tokenAccount) owners.set(tokenAccount, bal.owner); + } + return owners; +} + function toActivity( walletAddress: string, signature: string, @@ -244,17 +260,20 @@ function toActivity( const tokenDelta = walletTokenDelta(walletAddress, parsedTx); if (!tokenDelta) return null; - const keys = parsedTx.transaction.message.accountKeys; - const walletTokenAccount = keys[tokenDelta.tokenAccountIndex]?.pubkey.toBase58(); const splTransfer = parsedTx.transaction.message.instructions .map(extractSplTransfer) - .find((ix) => ix?.mint === tokenDelta.mint && (ix.source === walletTokenAccount || ix.destination === walletTokenAccount)); + .find((ix) => + (ix?.mint === null || ix?.mint === tokenDelta.mint) && + (ix.source === tokenDelta.tokenAccount || ix.destination === tokenDelta.tokenAccount), + ); const direction: ActivityDirection = tokenDelta.amountBaseUnits < 0n ? "send" : "receive"; - const counterparty = - direction === "send" - ? splTransfer?.destination ?? tokenDelta.mint - : splTransfer?.source ?? tokenDelta.mint; + const owners = tokenAccountOwners(parsedTx); + const counterpartyTokenAccount = + direction === "send" ? splTransfer?.destination : splTransfer?.source; + const counterparty = counterpartyTokenAccount + ? owners.get(counterpartyTokenAccount) ?? counterpartyTokenAccount + : tokenDelta.mint; const amountAbs = tokenDelta.amountBaseUnits < 0n ? -tokenDelta.amountBaseUnits : tokenDelta.amountBaseUnits; const createdAt = blockTime ? blockTime * 1000 : Date.now(); From dfe274b0d4799ccd93ce31001ad51c287bb824ca Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:22:17 -0800 Subject: [PATCH 07/30] feat(receive): encode solana pay qr --- mobile_app/app/receive.tsx | 40 +++++++++++++++++++++++-- mobile_app/src/services/solanaPayUri.ts | 40 +++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 mobile_app/src/services/solanaPayUri.ts diff --git a/mobile_app/app/receive.tsx b/mobile_app/app/receive.tsx index 9e742ca4..682ad855 100644 --- a/mobile_app/app/receive.tsx +++ b/mobile_app/app/receive.tsx @@ -8,6 +8,7 @@ import { Share, StyleSheet, Text, + TextInput, View, } from "react-native"; import Animated, { @@ -25,6 +26,7 @@ import { SegmentedControl, TokenLogo } from "@/components/primitives"; import * as haptics from "@/src/design-system/haptics"; import { useLxmfContext } from "@/context/LxmfContext"; import { useWallet } from "@/context/WalletContext"; +import { buildSolanaPayUri } from "@/src/services/solanaPayUri"; import { fontFamily as FF, useTheme } from "@/theme"; const DISMISS_DISTANCE = 120; @@ -54,6 +56,7 @@ export default function ReceiveScreen() { const { displayName } = useLxmfContext(); const [mode, setMode] = useState("standard"); const [copied, setCopied] = useState(false); + const [requestAmount, setRequestAmount] = useState(""); const copyPulse = useSharedValue(0); const dragY = useSharedValue(0); @@ -94,6 +97,17 @@ export default function ReceiveScreen() { const isStealth = mode === "stealth"; const activeAddress = isStealth ? stealthAddress : walletAddress; + const qrValue = useMemo(() => { + if (!activeAddress) return ""; + if (isStealth) return activeAddress; + return buildSolanaPayUri({ + recipient: activeAddress, + amount: requestAmount, + label: "AnonMesh", + message: `${alias} on AnonMesh`, + memo: "anonmesh-receive", + }); + }, [activeAddress, alias, isStealth, requestAmount]); async function handleCopy() { if (!activeAddress) return; @@ -187,11 +201,29 @@ export default function ReceiveScreen() { logoMargin={3} logoSize={36} size={186} - value={activeAddress} + value={qrValue} /> + {!isStealth && ( + + REQUEST + + SOL + + )} + {alias} @@ -204,7 +236,7 @@ export default function ReceiveScreen() { - SOLANA{isStealth ? " · STEALTH" : ""} + {isStealth ? "SOLANA · STEALTH" : "SOLANA PAY"} @@ -297,6 +329,10 @@ const S = StyleSheet.create({ qrCard: { backgroundColor: "#FFFFFF", borderRadius: 16, padding: 10, borderWidth: 0.5 }, alias: { fontFamily: FF.sansSb, fontSize: 15 }, mono: { fontFamily: FF.mono, fontSize: 12 }, + amountBox: { alignItems: "center", borderRadius: 14, borderWidth: 0.5, flexDirection: "row", gap: 8, minHeight: 44, paddingHorizontal: 12, width: "100%" }, + amountLabel: { fontFamily: FF.sansMd, fontSize: 9.5, letterSpacing: 1.5, textTransform: "uppercase" }, + amountInput: { flex: 1, fontFamily: FF.mono, fontSize: 15, minWidth: 0, paddingVertical: 8, textAlign: "right" }, + amountUnit: { fontFamily: FF.sansMd, fontSize: 10, letterSpacing: 1.2 }, networkRow: { flexDirection: "row", alignItems: "center", gap: 6 }, networkLabel: { fontFamily: FF.sansMd, fontSize: 9.5, letterSpacing: 2, textTransform: "uppercase" }, stealthNote: { fontFamily: FF.sansMd, fontSize: 11, letterSpacing: 0.2, textAlign: "center" }, diff --git a/mobile_app/src/services/solanaPayUri.ts b/mobile_app/src/services/solanaPayUri.ts new file mode 100644 index 00000000..843bc474 --- /dev/null +++ b/mobile_app/src/services/solanaPayUri.ts @@ -0,0 +1,40 @@ +export interface SolanaPayUriParams { + recipient: string; + amount?: string; + label?: string; + message?: string; + memo?: string; +} + +const AMOUNT_RE = /^\d+(\.\d{1,9})?$/; + +function normalizeAmount(amount: string | undefined): string | null { + const trimmed = amount?.trim(); + if (!trimmed || !AMOUNT_RE.test(trimmed)) return null; + const numeric = Number(trimmed); + if (!Number.isFinite(numeric) || numeric <= 0) return null; + return trimmed.replace(/^0+(?=\d)/, ""); +} + +export function buildSolanaPayUri({ + recipient, + amount, + label = "AnonMesh", + message = "AnonMesh receive", + memo, +}: SolanaPayUriParams): string { + const trimmedRecipient = recipient.trim(); + if (!trimmedRecipient) { + throw new Error("Recipient is required"); + } + + const params = new URLSearchParams(); + const normalizedAmount = normalizeAmount(amount); + if (normalizedAmount) params.set("amount", normalizedAmount); + if (label.trim()) params.set("label", label.trim()); + if (message.trim()) params.set("message", message.trim()); + if (memo?.trim()) params.set("memo", memo.trim()); + + const query = params.toString(); + return `solana:${trimmedRecipient}${query ? `?${query}` : ""}`; +} From 15442c86724016664f276d0c3047412dec4e199c Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:24:35 -0800 Subject: [PATCH 08/30] feat(send): remember recent recipients --- .../components/send/RecipientPicker.tsx | 91 ++++++++++++++ mobile_app/components/send/ReviewCard.tsx | 3 + mobile_app/src/services/addressBook.ts | 113 ++++++++++++++++++ mobile_app/src/storage/index.ts | 2 + 4 files changed, 209 insertions(+) create mode 100644 mobile_app/src/services/addressBook.ts diff --git a/mobile_app/components/send/RecipientPicker.tsx b/mobile_app/components/send/RecipientPicker.tsx index 3dabd0e3..ac151851 100644 --- a/mobile_app/components/send/RecipientPicker.tsx +++ b/mobile_app/components/send/RecipientPicker.tsx @@ -20,6 +20,7 @@ import { TokenPicker, tokenByName } from "@/components/send/TokenPicker"; import type { TokenOption } from "@/components/send/TokenPicker"; import * as haptics from "@/src/design-system/haptics"; import { useWalletBalance } from "@/src/hooks/useWalletBalance"; +import { useAddressBook } from "@/src/services/addressBook"; import { DEMO_MODE, DEMO_RECIPIENT_ADDRESS } from "@/src/utils/demoMode"; import { fontFamily as FF, useTheme } from "@/theme"; @@ -92,6 +93,7 @@ export function RecipientPicker() { const [selectedSymbol, setSelectedSymbol] = useState("SOL"); const [pickerOpen, setPickerOpen] = useState(false); const { tokens } = useWalletBalance(); + const { entries: addressBook } = useAddressBook(); const token: TokenOption = tokenByName(selectedSymbol, tokens); const trimmedAddress = address.trim(); @@ -131,6 +133,11 @@ export function RecipientPicker() { setAddress(DEMO_RECIPIENT_ADDRESS); } + function handleSelectRecent(pubkey: string) { + haptics.select(); + setAddress(pubkey); + } + return ( @@ -186,6 +193,49 @@ export function RecipientPicker() { + {addressBook.length > 0 ? ( + + RECENT + + {addressBook.slice(0, 5).map((entry) => ( + handleSelectRecent(entry.pubkey)} + style={({ pressed }) => [ + S.recentRow, + { + backgroundColor: colors.surface2, + borderColor: colors.border, + opacity: pressed ? 0.72 : 1, + }, + ]} + > + + + {entry.label} + + + {shortAddress(entry.pubkey)} + + + + + {entry.count} + + + + ))} + + + ) : null} + {/* ── Address tile ── */} TO @@ -353,6 +403,47 @@ const S = StyleSheet.create({ fontSize: 12, }, + // recent recipients + recentList: { + gap: 8, + }, + recentRow: { + alignItems: "center", + borderRadius: 12, + borderWidth: 0.5, + flexDirection: "row", + gap: 10, + minHeight: 52, + paddingHorizontal: 12, + paddingVertical: 9, + }, + recentMeta: { + flex: 1, + gap: 2, + minWidth: 0, + }, + recentLabel: { + fontFamily: FF.sansSb, + fontSize: 13, + }, + recentAddress: { + fontFamily: FF.mono, + fontSize: 11, + }, + recentCount: { + alignItems: "center", + borderRadius: 10, + borderWidth: 0.5, + height: 28, + justifyContent: "center", + minWidth: 28, + paddingHorizontal: 8, + }, + recentCountText: { + fontFamily: FF.mono, + fontSize: 11, + }, + // address tile addressRow: { alignItems: "center", diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index 2eb4fe07..397d6953 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -9,6 +9,7 @@ import { SendScaffold } from "@/components/send/SendScaffold"; import { useWallet } from "@/context/WalletContext"; import * as haptics from "@/src/design-system/haptics"; import { useNetworkMode } from "@/src/hooks/useNetworkMode"; +import { saveAddressBookRecipient } from "@/src/services/addressBook"; import { estimateSplTransferFeeLamports, estimateSolTransferFeeLamports, @@ -214,6 +215,8 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review decimals: tokenDecimals, }); + await saveAddressBookRecipient(to); + router.push({ pathname: "/send/success", params: { amount, symbol, txId: result.signature }, diff --git a/mobile_app/src/services/addressBook.ts b/mobile_app/src/services/addressBook.ts new file mode 100644 index 00000000..cb87eb53 --- /dev/null +++ b/mobile_app/src/services/addressBook.ts @@ -0,0 +1,113 @@ +import { PublicKey } from "@solana/web3.js"; +import { useCallback, useEffect, useState } from "react"; + +import { SecureKeys, secureGet, secureSet } from "@/src/storage"; + +export interface AddressBookEntry { + label: string; + pubkey: string; + lastUsed: number; + count: number; +} + +const MAX_RECIPIENTS = 50; + +function shortAddress(addr: string): string { + return `${addr.slice(0, 4)}...${addr.slice(-4)}`; +} + +function normalizePubkey(pubkey: string): string | null { + try { + return new PublicKey(pubkey.trim()).toBase58(); + } catch { + return null; + } +} + +function normalizeEntry(entry: Partial): AddressBookEntry | null { + if (typeof entry.pubkey !== "string") return null; + const pubkey = normalizePubkey(entry.pubkey); + if (!pubkey) return null; + + const count = Number.isFinite(entry.count) && Number(entry.count) > 0 + ? Math.floor(Number(entry.count)) + : 1; + const lastUsed = Number.isFinite(entry.lastUsed) && Number(entry.lastUsed) > 0 + ? Number(entry.lastUsed) + : Date.now(); + const label = typeof entry.label === "string" && entry.label.trim() + ? entry.label.trim().slice(0, 48) + : shortAddress(pubkey); + + return { label, pubkey, lastUsed, count }; +} + +function sortAndCap(entries: AddressBookEntry[]): AddressBookEntry[] { + return [...entries] + .sort((a, b) => b.lastUsed - a.lastUsed) + .slice(0, MAX_RECIPIENTS); +} + +export async function readAddressBook(): Promise { + const raw = await secureGet(SecureKeys.ADDRESS_BOOK); + if (!raw) return []; + + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const deduped = new Map(); + for (const item of parsed) { + const entry = normalizeEntry(item as Partial); + if (!entry) continue; + const existing = deduped.get(entry.pubkey); + if (!existing || entry.lastUsed > existing.lastUsed) { + deduped.set(entry.pubkey, entry); + } + } + return sortAndCap([...deduped.values()]); + } catch { + return []; + } +} + +export async function writeAddressBook(entries: AddressBookEntry[]): Promise { + await secureSet(SecureKeys.ADDRESS_BOOK, JSON.stringify(sortAndCap(entries))); +} + +export async function saveAddressBookRecipient(pubkey: string, label?: string): Promise { + const normalized = normalizePubkey(pubkey); + if (!normalized) return readAddressBook(); + + const now = Date.now(); + const entries = await readAddressBook(); + const existing = entries.find((entry) => entry.pubkey === normalized); + const nextEntry: AddressBookEntry = { + label: label?.trim() || existing?.label || shortAddress(normalized), + pubkey: normalized, + lastUsed: now, + count: (existing?.count ?? 0) + 1, + }; + + const next = [nextEntry, ...entries.filter((entry) => entry.pubkey !== normalized)]; + await writeAddressBook(next); + return sortAndCap(next); +} + +export function useAddressBook() { + const [entries, setEntries] = useState([]); + + const refresh = useCallback(async () => { + setEntries(await readAddressBook()); + }, []); + + const saveRecipient = useCallback(async (pubkey: string, label?: string) => { + const next = await saveAddressBookRecipient(pubkey, label); + setEntries(next); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { entries, refresh, saveRecipient }; +} diff --git a/mobile_app/src/storage/index.ts b/mobile_app/src/storage/index.ts index 3c1ff8ea..6b905e51 100644 --- a/mobile_app/src/storage/index.ts +++ b/mobile_app/src/storage/index.ts @@ -26,6 +26,8 @@ export const SecureKeys = { WALLET_AES_KEY: 'anon_wallet_aes_v1', WALLET_PUBKEY: 'anon_wallet_pubkey_v1', WALLET_MARKER: 'anon_wallet_marker_v1', + // Wallet address book — local-only recent recipients, never synced + ADDRESS_BOOK: 'address_book_v1', // MWA auth token MWA_TOKEN: 'mwa_auth_token_v1', } as const; From 0cc283e8f4c6a3c645fa739f36cff8b3f8d0ea7d Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:31:37 -0800 Subject: [PATCH 09/30] feat(onboarding): add first-run tutorial --- mobile_app/app/_layout.tsx | 1 + mobile_app/app/onboarding.tsx | 23 +- mobile_app/app/tutorial.tsx | 265 +++++++++++++++++++++++ mobile_app/src/services/tutorialState.ts | 11 + mobile_app/src/storage/index.ts | 2 + 5 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 mobile_app/app/tutorial.tsx create mode 100644 mobile_app/src/services/tutorialState.ts diff --git a/mobile_app/app/_layout.tsx b/mobile_app/app/_layout.tsx index b78584e0..264e4d44 100644 --- a/mobile_app/app/_layout.tsx +++ b/mobile_app/app/_layout.tsx @@ -91,6 +91,7 @@ function AppShell() { + diff --git a/mobile_app/app/onboarding.tsx b/mobile_app/app/onboarding.tsx index 6b365ce7..f4a72865 100644 --- a/mobile_app/app/onboarding.tsx +++ b/mobile_app/app/onboarding.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { Animated, Image, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; +import { type Href, useRouter } from 'expo-router'; import * as LocalAuthentication from 'expo-local-authentication'; import { useWallet } from '@/context/WalletContext'; import { useLxmfContext } from '@/context/LxmfContext'; @@ -12,6 +12,9 @@ import { LoadingOverlay, } from '@/components/onboarding'; import { BG } from '@/components/onboarding/constants'; +import { hasCompletedTutorial } from '@/src/services/tutorialState'; + +const TUTORIAL_ROUTE = '/tutorial' as Href; export default function OnboardingScreen() { const router = useRouter(); @@ -46,9 +49,21 @@ const overlayOpacity = useRef(new Animated.Value(0)).current; useEffect(() => { if (!isConnected || !publicKey) return; - const delay = 0; - const t = setTimeout(() => router.replace('/(tabs)'), delay); - return () => clearTimeout(t); + let cancelled = false; + const t = setTimeout(() => { + hasCompletedTutorial() + .then((completed) => { + if (cancelled) return; + router.replace(completed ? '/(tabs)' : TUTORIAL_ROUTE); + }) + .catch(() => { + if (!cancelled) router.replace(TUTORIAL_ROUTE); + }); + }, 0); + return () => { + cancelled = true; + clearTimeout(t); + }; }, [isConnected, publicKey, router]); const handleCreate = useCallback(async () => { diff --git a/mobile_app/app/tutorial.tsx b/mobile_app/app/tutorial.tsx new file mode 100644 index 00000000..ce2ce8ef --- /dev/null +++ b/mobile_app/app/tutorial.tsx @@ -0,0 +1,265 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; +import { router } from "expo-router"; + +import { DepthButton, Icon } from "@/components/primitives"; +import { useLxmfContext } from "@/context/LxmfContext"; +import { useWallet } from "@/context/WalletContext"; +import { markTutorialCompleted } from "@/src/services/tutorialState"; +import { useTheme } from "@/theme"; + +type Slide = { + readonly icon: React.ComponentProps["name"]; + readonly kicker: string; + readonly title: string; + readonly body: string; + readonly statLabel: string; + readonly statValue: string; +}; + +function shortAddress(value: string | null | undefined): string { + if (!value) return "Ready"; + if (value.length <= 12) return value; + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +export default function TutorialScreen() { + const { colors, fontFamily } = useTheme(); + const insets = useSafeAreaInsets(); + const { publicKey } = useWallet(); + const { bleActive, displayName, myAddress, peers } = useLxmfContext(); + const [index, setIndex] = useState(0); + + const slides = useMemo(() => { + const onlinePeers = peers.filter((peer) => peer.online).length; + return [ + { + icon: "identity-chip", + kicker: "Identity", + title: "AnonMesh is your encrypted mesh identity.", + body: "Your wallet and LXMF address stay on this device. Messages move over the mesh, and payments settle through Solana when you choose to send.", + statLabel: "Wallet", + statValue: shortAddress(publicKey?.toBase58()), + }, + { + icon: "signal", + kicker: "Evidence", + title: "Nearby counts come from live radio signals.", + body: "Bluetooth scanning and advertising let the app prove local mesh reachability. Location permission is requested only because Android requires it for BLE discovery.", + statLabel: "Radio", + statValue: bleActive ? "Active" : "Standby", + }, + { + icon: "send", + kicker: "First action", + title: "Start with one concrete connection.", + body: "Share your receive QR, scan another peer, or open the wallet tab when you are ready to move value.", + statLabel: "Peers", + statValue: onlinePeers > 0 ? `${onlinePeers} online` : "Scanning", + }, + ]; + }, [bleActive, peers, publicKey]); + + const slide = slides[index]; + const isLast = index === slides.length - 1; + + const finish = useCallback(() => { + markTutorialCompleted() + .catch(() => undefined) + .finally(() => router.replace("/(tabs)")); + }, []); + + const next = useCallback(() => { + if (isLast) { + finish(); + return; + } + setIndex((current) => Math.min(current + 1, slides.length - 1)); + }, [finish, isLast, slides.length]); + + const back = useCallback(() => { + setIndex((current) => Math.max(current - 1, 0)); + }, []); + + return ( + + + + {displayName || "AnonMesh"} + + + Skip + + + + + + + + + {slide.kicker} + {slide.title} + {slide.body} + + + + + {slide.statLabel} + + + {slide.statValue} + + + + + {shortAddress(myAddress)} + + + + + + {slides.map((item, dotIndex) => ( + + ))} + + + + + + + + + ); +} + +const S = StyleSheet.create({ + root: { + flex: 1, + }, + header: { + alignItems: "center", + flexDirection: "row", + justifyContent: "space-between", + paddingHorizontal: 20, + paddingTop: 8, + }, + brand: { + fontSize: 17, + }, + skip: { + minHeight: 36, + justifyContent: "center", + paddingHorizontal: 8, + }, + skipText: { + fontSize: 14, + }, + content: { + alignItems: "center", + flexGrow: 1, + justifyContent: "center", + paddingHorizontal: 24, + paddingTop: 28, + }, + iconShell: { + alignItems: "center", + borderRadius: 26, + borderWidth: 1, + height: 96, + justifyContent: "center", + marginBottom: 28, + width: 96, + }, + kicker: { + fontSize: 12, + marginBottom: 10, + textTransform: "uppercase", + }, + title: { + fontSize: 30, + lineHeight: 36, + marginBottom: 16, + textAlign: "center", + }, + body: { + fontSize: 16, + lineHeight: 24, + maxWidth: 360, + textAlign: "center", + }, + statusPanel: { + alignItems: "center", + borderRadius: 8, + borderWidth: 1, + flexDirection: "row", + justifyContent: "space-between", + marginTop: 34, + maxWidth: 420, + minHeight: 72, + paddingHorizontal: 16, + width: "100%", + }, + statusLabel: { + fontSize: 11, + marginBottom: 4, + textTransform: "uppercase", + }, + statusValue: { + fontSize: 18, + }, + identityPill: { + borderRadius: 999, + borderWidth: 1, + maxWidth: "48%", + paddingHorizontal: 12, + paddingVertical: 8, + }, + identityText: { + fontSize: 13, + }, + dots: { + flexDirection: "row", + gap: 8, + marginTop: 28, + }, + dot: { + borderRadius: 999, + height: 8, + }, + footer: { + flexDirection: "row", + gap: 12, + paddingHorizontal: 20, + paddingTop: 8, + }, + footerButton: { + flex: 1, + }, +}); diff --git a/mobile_app/src/services/tutorialState.ts b/mobile_app/src/services/tutorialState.ts new file mode 100644 index 00000000..d6d638ec --- /dev/null +++ b/mobile_app/src/services/tutorialState.ts @@ -0,0 +1,11 @@ +import { SecureKeys, secureGet, secureSet } from "@/src/storage"; + +const COMPLETE_VALUE = "true"; + +export async function hasCompletedTutorial(): Promise { + return (await secureGet(SecureKeys.TUTORIAL_COMPLETED)) === COMPLETE_VALUE; +} + +export async function markTutorialCompleted(): Promise { + await secureSet(SecureKeys.TUTORIAL_COMPLETED, COMPLETE_VALUE); +} diff --git a/mobile_app/src/storage/index.ts b/mobile_app/src/storage/index.ts index 6b905e51..446b71c9 100644 --- a/mobile_app/src/storage/index.ts +++ b/mobile_app/src/storage/index.ts @@ -28,6 +28,8 @@ export const SecureKeys = { WALLET_MARKER: 'anon_wallet_marker_v1', // Wallet address book — local-only recent recipients, never synced ADDRESS_BOOK: 'address_book_v1', + // First-run education gate — kept local to this install + TUTORIAL_COMPLETED: 'tutorial_completed', // MWA auth token MWA_TOKEN: 'mwa_auth_token_v1', } as const; From 6f155fab96f9e568b3c8a4509c6df4eb68afa804 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:37:14 -0800 Subject: [PATCH 10/30] feat(settings): manage address book --- mobile_app/app/_layout.tsx | 1 + mobile_app/app/contacts.tsx | 391 +++++++++++++++++++++++++ mobile_app/screens/SettingsScreen.tsx | 4 +- mobile_app/src/services/addressBook.ts | 40 ++- 4 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 mobile_app/app/contacts.tsx diff --git a/mobile_app/app/_layout.tsx b/mobile_app/app/_layout.tsx index 264e4d44..3e58a382 100644 --- a/mobile_app/app/_layout.tsx +++ b/mobile_app/app/_layout.tsx @@ -93,6 +93,7 @@ function AppShell() { + diff --git a/mobile_app/app/contacts.tsx b/mobile_app/app/contacts.tsx new file mode 100644 index 00000000..a3b0e65d --- /dev/null +++ b/mobile_app/app/contacts.tsx @@ -0,0 +1,391 @@ +import { PublicKey } from "@solana/web3.js"; +import { Feather } from "@expo/vector-icons"; +import { router } from "expo-router"; +import React, { useMemo, useState } from "react"; +import { + Alert, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; + +import { DepthButton } from "@/components/primitives"; +import * as haptics from "@/src/design-system/haptics"; +import type { AddressBookEntry } from "@/src/services/addressBook"; +import { useAddressBook } from "@/src/services/addressBook"; +import { fontFamily as FF, useTheme } from "@/theme"; + +function shortAddress(addr: string): string { + if (addr.length <= 16) return addr; + return `${addr.slice(0, 6)}...${addr.slice(-6)}`; +} + +function normalizePubkey(value: string): string | null { + try { + return new PublicKey(value.trim()).toBase58(); + } catch { + return null; + } +} + +function relativeDate(ms: number): string { + const diff = Date.now() - ms; + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return `${Math.floor(diff / 86_400_000)}d ago`; +} + +function ContactRow({ + entry, + onDelete, + onSave, +}: { + readonly entry: AddressBookEntry; + readonly onDelete: (pubkey: string) => void; + readonly onSave: (pubkey: string, label: string) => void; +}) { + const { colors } = useTheme(); + const [editing, setEditing] = useState(false); + const [label, setLabel] = useState(entry.label); + + function handleSave() { + haptics.confirm(); + onSave(entry.pubkey, label); + setEditing(false); + } + + function confirmDelete() { + haptics.warning(); + Alert.alert("Delete recipient?", shortAddress(entry.pubkey), [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => onDelete(entry.pubkey), + }, + ]); + } + + return ( + + + + + + + {editing ? ( + + ) : ( + + {entry.label} + + )} + + {shortAddress(entry.pubkey)} + + + + + + + {entry.count} sends - {relativeDate(entry.lastUsed)} + + + {editing ? ( + + + + ) : ( + setEditing(true)} style={S.iconButton}> + + + )} + + + + + + + ); +} + +export default function ContactsScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { entries, saveRecipient, updateRecipient, deleteRecipient } = useAddressBook(); + const [label, setLabel] = useState(""); + const [pubkey, setPubkey] = useState(""); + + const normalizedPubkey = normalizePubkey(pubkey); + const sortedEntries = useMemo(() => entries, [entries]); + const canAdd = !!normalizedPubkey; + + async function handleAdd() { + if (!normalizedPubkey) return; + haptics.confirm(); + await saveRecipient(normalizedPubkey, label); + setLabel(""); + setPubkey(""); + } + + return ( + + + + LOCAL ONLY + address book + + router.back()} + style={[S.closeButton, { backgroundColor: colors.surface1, borderColor: colors.border }]} + > + + + + + + + + ADD RECIPIENT + + + {pubkey.length > 0 && !normalizedPubkey ? ( + Enter a valid Solana address. + ) : null} + + + + + RECENTS + {sortedEntries.length}/50 + + + {sortedEntries.length === 0 ? ( + + + No saved recipients + + Successful sends appear here automatically. You can also add a trusted devnet address manually. + + + ) : ( + + {sortedEntries.map((entry) => ( + + ))} + + )} + + + + ); +} + +const S = StyleSheet.create({ + root: { + flex: 1, + }, + flex: { + flex: 1, + }, + header: { + alignItems: "center", + flexDirection: "row", + justifyContent: "space-between", + paddingHorizontal: 20, + paddingVertical: 16, + }, + kicker: { + fontFamily: FF.sansMd, + fontSize: 10, + letterSpacing: 2, + marginBottom: 2, + textTransform: "uppercase", + }, + title: { + fontFamily: FF.sansBold, + fontSize: 28, + letterSpacing: -0.5, + }, + closeButton: { + alignItems: "center", + borderRadius: 18, + borderWidth: 0.5, + height: 36, + justifyContent: "center", + width: 36, + }, + content: { + gap: 16, + paddingHorizontal: 16, + }, + addCard: { + borderRadius: 16, + borderWidth: 0.5, + gap: 10, + padding: 14, + }, + sectionLabel: { + fontFamily: FF.sansMd, + fontSize: 10, + letterSpacing: 2, + textTransform: "uppercase", + }, + input: { + borderRadius: 12, + borderWidth: 0.5, + fontFamily: FF.sans, + fontSize: 15, + minHeight: 48, + paddingHorizontal: 12, + }, + pubkeyInput: { + fontFamily: FF.mono, + fontSize: 13, + }, + errorText: { + fontFamily: FF.sansMd, + fontSize: 12, + }, + listHeader: { + alignItems: "center", + flexDirection: "row", + justifyContent: "space-between", + }, + count: { + fontFamily: FF.mono, + fontSize: 12, + }, + empty: { + alignItems: "center", + borderRadius: 16, + borderWidth: 0.5, + gap: 8, + padding: 24, + }, + emptyTitle: { + fontFamily: FF.sansMd, + fontSize: 16, + }, + emptyBody: { + fontFamily: FF.sans, + fontSize: 13, + lineHeight: 19, + textAlign: "center", + }, + list: { + gap: 10, + }, + row: { + borderRadius: 16, + borderWidth: 0.5, + gap: 12, + padding: 14, + }, + rowTop: { + alignItems: "center", + flexDirection: "row", + gap: 12, + }, + avatar: { + alignItems: "center", + borderRadius: 14, + borderWidth: 0.5, + height: 44, + justifyContent: "center", + width: 44, + }, + rowMeta: { + flex: 1, + gap: 4, + minWidth: 0, + }, + label: { + fontFamily: FF.sansMd, + fontSize: 16, + }, + labelInput: { + borderRadius: 10, + borderWidth: 0.5, + fontFamily: FF.sansMd, + fontSize: 16, + minHeight: 40, + paddingHorizontal: 10, + }, + address: { + fontFamily: FF.mono, + fontSize: 12, + }, + rowFooter: { + alignItems: "center", + flexDirection: "row", + justifyContent: "space-between", + }, + metaText: { + fontFamily: FF.sans, + fontSize: 12, + }, + actions: { + flexDirection: "row", + gap: 8, + }, + iconButton: { + alignItems: "center", + height: 44, + justifyContent: "center", + width: 44, + }, +}); diff --git a/mobile_app/screens/SettingsScreen.tsx b/mobile_app/screens/SettingsScreen.tsx index 2ceb570e..8ee77516 100644 --- a/mobile_app/screens/SettingsScreen.tsx +++ b/mobile_app/screens/SettingsScreen.tsx @@ -7,7 +7,7 @@ import { useGlass } from '@/hooks/useGlass'; import { Pill } from '@/components/ui/Pill'; import { useWallet } from '@/context/WalletContext'; import { useLxmfContext } from '@/context/LxmfContext'; -import { useRouter } from 'expo-router'; +import { type Href, useRouter } from 'expo-router'; import * as Clipboard from 'expo-clipboard'; import * as Haptics from 'expo-haptics'; import { useNotificationEnabled } from '@/hooks/useNotificationEnabled'; @@ -23,6 +23,7 @@ import { const SCREEN_W = Dimensions.get('window').width; const CARD_OUTER = 32; // 16px padding each side +const CONTACTS_ROUTE = '/contacts' as Href; export default function SettingsScreen() { const { colors } = useTheme(); @@ -230,6 +231,7 @@ export default function SettingsScreen() { { if (v) setBiometric(true); else setDisableBioOpen(true); }} />} /> + } onPress={() => router.push(CONTACTS_ROUTE)} /> } onPress={() => setRotateOpen(true)} /> } onPress={() => setExportOpen(true)} last /> diff --git a/mobile_app/src/services/addressBook.ts b/mobile_app/src/services/addressBook.ts index cb87eb53..6913553a 100644 --- a/mobile_app/src/services/addressBook.ts +++ b/mobile_app/src/services/addressBook.ts @@ -93,6 +93,34 @@ export async function saveAddressBookRecipient(pubkey: string, label?: string): return sortAndCap(next); } +export async function updateAddressBookRecipient( + pubkey: string, + label: string, +): Promise { + const normalized = normalizePubkey(pubkey); + if (!normalized) return readAddressBook(); + + const entries = await readAddressBook(); + const existing = entries.find((entry) => entry.pubkey === normalized); + if (!existing) return entries; + + const nextLabel = label.trim().slice(0, 48) || shortAddress(normalized); + const next = entries.map((entry) => + entry.pubkey === normalized ? { ...entry, label: nextLabel } : entry, + ); + await writeAddressBook(next); + return sortAndCap(next); +} + +export async function deleteAddressBookRecipient(pubkey: string): Promise { + const normalized = normalizePubkey(pubkey); + if (!normalized) return readAddressBook(); + + const next = (await readAddressBook()).filter((entry) => entry.pubkey !== normalized); + await writeAddressBook(next); + return next; +} + export function useAddressBook() { const [entries, setEntries] = useState([]); @@ -105,9 +133,19 @@ export function useAddressBook() { setEntries(next); }, []); + const updateRecipient = useCallback(async (pubkey: string, label: string) => { + const next = await updateAddressBookRecipient(pubkey, label); + setEntries(next); + }, []); + + const deleteRecipient = useCallback(async (pubkey: string) => { + const next = await deleteAddressBookRecipient(pubkey); + setEntries(next); + }, []); + useEffect(() => { refresh(); }, [refresh]); - return { entries, refresh, saveRecipient }; + return { entries, refresh, saveRecipient, updateRecipient, deleteRecipient }; } From 25ba066fba8517491d14a37c03a77031a19f8e8d Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:38:37 -0800 Subject: [PATCH 11/30] feat(onboarding): clarify empty states --- mobile_app/components/home/NearbyPeersCard.tsx | 4 ++-- mobile_app/components/home/RecentActivity.tsx | 4 ++-- mobile_app/components/nodes/MeshMap.tsx | 7 ++++--- mobile_app/components/wallet/ReceivePanel.tsx | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/mobile_app/components/home/NearbyPeersCard.tsx b/mobile_app/components/home/NearbyPeersCard.tsx index 60edf79c..51f47ff7 100644 --- a/mobile_app/components/home/NearbyPeersCard.tsx +++ b/mobile_app/components/home/NearbyPeersCard.tsx @@ -58,14 +58,14 @@ export function NearbyPeersCard() { // offline : "Mesh offline" // ble peers : "N nearby" (BLE physical proximity) // only hub : "Connected via hub" (TCP-only fallback) - // nothing : "Scanning for peers…" + // nothing : "No nearby peers yet" const label = !isRunning ? "Mesh offline" : freshCount > 0 ? `${freshCount.toLocaleString()} ${freshCount === 1 ? "peer" : "peers"} nearby` : hubCount > 0 ? `Connected via hub · ${hubCount.toLocaleString()} reachable` - : "Scanning for peers…"; + : "No nearby peers yet"; const anyLive = freshCount > 0 || hubCount > 0; const pillLabel: string = !isRunning diff --git a/mobile_app/components/home/RecentActivity.tsx b/mobile_app/components/home/RecentActivity.tsx index 99d02319..41457d76 100644 --- a/mobile_app/components/home/RecentActivity.tsx +++ b/mobile_app/components/home/RecentActivity.tsx @@ -83,7 +83,7 @@ export function RecentActivity({ limit = DEFAULT_LIMIT }: RecentActivityProps) { fontSize: fontSize.md, }} > - {activityError ? (isRateLimit ? "Devnet is rate-limiting us" : "Activity unavailable") : "No activity yet"} + {activityError ? (isRateLimit ? "Devnet is rate-limiting us" : "Activity unavailable") : "First transfer lands here"} {activityError ? "Pull to refresh in a moment. Public devnet throttles heavy wallets." - : "Sent or received SOL will show up here."} + : "Send a tiny devnet payment or share your receive QR; the real signature and fee will be saved here."} ); diff --git a/mobile_app/components/nodes/MeshMap.tsx b/mobile_app/components/nodes/MeshMap.tsx index 9203e5a3..f339c350 100644 --- a/mobile_app/components/nodes/MeshMap.tsx +++ b/mobile_app/components/nodes/MeshMap.tsx @@ -428,8 +428,8 @@ export const MeshMap = memo(function MeshMap({ nodes, selected, onSelect, syncin {/* Empty state — no peers and not syncing */} {nodes.length === 0 && !syncing && ( - - awaiting peers… + + open AnonMesh on a nearby phone )} @@ -565,7 +565,8 @@ const S = StyleSheet.create({ label: { position: 'absolute', width: 36, textAlign: 'center', fontFamily: fontFamily.sansMd, fontSize: 6, letterSpacing: 0.3 }, empty: { position: 'absolute', fontFamily: fontFamily.sansMd, - fontSize: 9, letterSpacing: 1.5, textTransform: 'uppercase' }, + fontSize: 9, letterSpacing: 1.5, textAlign: 'center', + textTransform: 'uppercase', width: 240 }, strip: { position: 'absolute', bottom: 0, left: 0, right: 0, flexDirection: 'row', alignItems: 'center', gap: 7, diff --git a/mobile_app/components/wallet/ReceivePanel.tsx b/mobile_app/components/wallet/ReceivePanel.tsx index 7e088ee4..31b49928 100644 --- a/mobile_app/components/wallet/ReceivePanel.tsx +++ b/mobile_app/components/wallet/ReceivePanel.tsx @@ -42,8 +42,8 @@ export const ReceivePanel = memo(function ReceivePanel() { - - CONFIDENTIAL · MPC-SHIELDED RECEIVE + + SHARE THIS QR - YOUR FIRST INBOUND SHOWS IN ACTIVITY From 371cde06dc79e72a44a7f4f04b392226685af0ca Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:52:02 -0800 Subject: [PATCH 12/30] chore(test): add tier0 validation script --- mobile_app/package.json | 3 +- mobile_app/scripts/validate-tier0.sh | 48 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100755 mobile_app/scripts/validate-tier0.sh diff --git a/mobile_app/package.json b/mobile_app/package.json index 4951435d..8486b23a 100644 --- a/mobile_app/package.json +++ b/mobile_app/package.json @@ -9,7 +9,8 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", - "lint": "expo lint" + "lint": "expo lint", + "validate:tier0": "bash ./scripts/validate-tier0.sh" }, "dependencies": { "@expo-google-fonts/space-grotesk": "^0.4.1", diff --git a/mobile_app/scripts/validate-tier0.sh b/mobile_app/scripts/validate-tier0.sh new file mode 100755 index 00000000..301baca1 --- /dev/null +++ b/mobile_app/scripts/validate-tier0.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +EXPORT_DIR="${EXPORT_DIR:-/tmp/anonmesh-tier0-export}" + +cd "$ROOT_DIR" + +section() { + printf '\n==> %s\n' "$1" +} + +section "TypeScript" +npx tsc --noEmit + +section "Lint" +npm run lint + +section "Expo dependency check" +npx expo install --check + +section "Fake money-state scan" +if rg -n "sim_xxx|Demo transfer|fake success|simulated success|simulated transfer" app components src; then + printf '\nFound forbidden fake transaction wording.\n' >&2 + exit 1 +fi + +section "Android JS export" +npx expo export --platform android --output-dir "$EXPORT_DIR" --clear + +section "Exported bundle secret scan" +if rg -n "api-key=your-key|your-key|BEGIN PRIVATE KEY|PRIVATE KEY-----|mnemonic phrase" "$EXPORT_DIR"; then + printf '\nFound example key material or private-key wording in exported bundle.\n' >&2 + exit 1 +fi + +section "Android native build" +(cd android && ./gradlew :app:assembleDebug) + +section "Connected Android devices" +adb devices -l || true + +cat <<'EOF' + +Tier 0 local validation completed. +Device smoke is still separate: cold boot, BLE permission flow, biometric recovery reveal, +screenshot blocking, real devnet SOL/SPL sends, and Explorer verification need physical devices. +EOF From 55519f499ab038c87429f6df842f4da867c67e77 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 17:54:14 -0800 Subject: [PATCH 13/30] chore(lint): clear existing warnings --- mobile_app/components/home/BalanceCard.tsx | 2 +- mobile_app/components/messages/MediaBubble.tsx | 3 +-- mobile_app/components/nodes/PendingCosigns.tsx | 3 +-- mobile_app/components/nodes/constants.ts | 2 +- mobile_app/components/primitives/IconButton.tsx | 2 +- mobile_app/components/primitives/NumericKeypad.tsx | 4 +--- mobile_app/components/settings/QRCode.tsx | 1 - mobile_app/components/ui/Input.tsx | 1 - 8 files changed, 6 insertions(+), 12 deletions(-) diff --git a/mobile_app/components/home/BalanceCard.tsx b/mobile_app/components/home/BalanceCard.tsx index 6f07da1f..9909a23c 100644 --- a/mobile_app/components/home/BalanceCard.tsx +++ b/mobile_app/components/home/BalanceCard.tsx @@ -38,7 +38,7 @@ function formatTokenAmount(amount: number, maxDecimals: number): string { } export function BalanceCard() { - const { colors, radii, spacing, fontFamily, fontSize } = useTheme(); + const { colors, spacing, fontFamily, fontSize } = useTheme(); const { hidden, toggle } = useHideBalance(); const { isConnected, publicKey } = useWallet(); const { solBalance, tokens, loading, lastFetched } = useWalletBalance(); diff --git a/mobile_app/components/messages/MediaBubble.tsx b/mobile_app/components/messages/MediaBubble.tsx index e1e83b61..b8507bd7 100644 --- a/mobile_app/components/messages/MediaBubble.tsx +++ b/mobile_app/components/messages/MediaBubble.tsx @@ -2,8 +2,7 @@ import React, { memo, useState } from 'react'; import { View, Text, Pressable, Modal, StyleSheet, Dimensions } from 'react-native'; import { Image } from 'expo-image'; import { Feather } from '@expo/vector-icons'; -import { useTheme } from '@/theme'; -import { fontFamily } from '@/theme'; +import { fontFamily, useTheme } from '@/theme'; import type { MediaMsg } from './types'; const SCREEN_W = Dimensions.get('window').width; diff --git a/mobile_app/components/nodes/PendingCosigns.tsx b/mobile_app/components/nodes/PendingCosigns.tsx index e4db3974..1e16fa42 100644 --- a/mobile_app/components/nodes/PendingCosigns.tsx +++ b/mobile_app/components/nodes/PendingCosigns.tsx @@ -3,8 +3,7 @@ import { NativeScrollEvent, NativeSyntheticEvent, ScrollView, StyleSheet, Text, View, Pressable, useWindowDimensions, } from 'react-native'; -import { Feather } from '@expo/vector-icons'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { Feather, MaterialCommunityIcons } from '@expo/vector-icons'; import { fontFamily, useTheme } from '@/theme'; import { useGlass } from '@/hooks/useGlass'; diff --git a/mobile_app/components/nodes/constants.ts b/mobile_app/components/nodes/constants.ts index c2922440..899fe1a6 100644 --- a/mobile_app/components/nodes/constants.ts +++ b/mobile_app/components/nodes/constants.ts @@ -1,6 +1,6 @@ type Iface = 'TCP' | 'BLE' | 'RNode'; -export const NODES: Array<{ handle: string; hops: number; iface: Iface; signal: number; latency: string; beacon?: boolean; online?: boolean; weak?: boolean }> = [ +export const NODES: { handle: string; hops: number; iface: Iface; signal: number; latency: string; beacon?: boolean; online?: boolean; weak?: boolean }[] = [ { handle: '@beacon_prime', hops: 0, iface: 'TCP', signal: 4, beacon: true, latency: '12ms' }, { handle: '@node_a1b2', hops: 1, iface: 'TCP', signal: 4, latency: '48ms' }, { handle: '@node_7f3a', hops: 3, iface: 'RNode', signal: 3, online: true, latency: '112ms' }, diff --git a/mobile_app/components/primitives/IconButton.tsx b/mobile_app/components/primitives/IconButton.tsx index 0d823871..719eef81 100644 --- a/mobile_app/components/primitives/IconButton.tsx +++ b/mobile_app/components/primitives/IconButton.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import { StyleProp, StyleSheet, ViewStyle } from "react-native"; import { Pressable } from "react-native-gesture-handler"; import Animated, { interpolate, diff --git a/mobile_app/components/primitives/NumericKeypad.tsx b/mobile_app/components/primitives/NumericKeypad.tsx index 0bf6b53e..56e36361 100644 --- a/mobile_app/components/primitives/NumericKeypad.tsx +++ b/mobile_app/components/primitives/NumericKeypad.tsx @@ -1,6 +1,6 @@ import { Feather } from "@expo/vector-icons"; import React, { useCallback } from "react"; -import { StyleSheet, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { Pressable } from "react-native-gesture-handler"; import Animated, { useAnimatedStyle, @@ -256,5 +256,3 @@ function KeyButton({ label, onPress }: { label: string; onPress: (key: string) = ); } - -const styles = StyleSheet.create({}); diff --git a/mobile_app/components/settings/QRCode.tsx b/mobile_app/components/settings/QRCode.tsx index 452757af..8c03d461 100644 --- a/mobile_app/components/settings/QRCode.tsx +++ b/mobile_app/components/settings/QRCode.tsx @@ -33,7 +33,6 @@ export function QRCode({ size = 180, data = 'anonmesh' }: Readonly<{ size?: numb alignItems: 'center', justifyContent: 'center', }}> Date: Wed, 6 May 2026 18:00:15 -0800 Subject: [PATCH 14/30] chore(test): cover tier0 service helpers --- mobile_app/package.json | 1 + .../scripts/validate-tier0-services.mjs | 89 +++++++++++++++ mobile_app/scripts/validate-tier0.sh | 3 + mobile_app/src/services/addressBook.ts | 101 +++-------------- mobile_app/src/services/addressBookCore.ts | 105 ++++++++++++++++++ 5 files changed, 214 insertions(+), 85 deletions(-) create mode 100644 mobile_app/scripts/validate-tier0-services.mjs create mode 100644 mobile_app/src/services/addressBookCore.ts diff --git a/mobile_app/package.json b/mobile_app/package.json index 8486b23a..6025fcf0 100644 --- a/mobile_app/package.json +++ b/mobile_app/package.json @@ -10,6 +10,7 @@ "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint", + "validate:tier0:services": "node --no-warnings --experimental-transform-types ./scripts/validate-tier0-services.mjs", "validate:tier0": "bash ./scripts/validate-tier0.sh" }, "dependencies": { diff --git a/mobile_app/scripts/validate-tier0-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs new file mode 100644 index 00000000..ebf6ef2d --- /dev/null +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { Keypair } from "@solana/web3.js"; + +const { buildSolanaPayUri } = await import("../src/services/solanaPayUri.ts"); +const { + MAX_ADDRESS_BOOK_RECIPIENTS, + normalizeAddressBookEntries, + normalizeAddressBookPubkey, + removeAddressBookEntry, + updateAddressBookEntryLabel, + upsertAddressBookEntry, +} = await import("../src/services/addressBookCore.ts"); + +function key(index) { + const seed = new Uint8Array(32); + seed.fill(index); + return Keypair.fromSeed(seed).publicKey.toBase58(); +} + +function testSolanaPayUri() { + assert.equal( + buildSolanaPayUri({ + recipient: " 11111111111111111111111111111111 ", + amount: "001.230000001", + label: "Anon Mesh", + message: "scan me", + memo: "receive memo", + }), + "solana:11111111111111111111111111111111?amount=1.230000001&label=Anon+Mesh&message=scan+me&memo=receive+memo", + ); + + assert.equal( + buildSolanaPayUri({ + recipient: "11111111111111111111111111111111", + amount: "0", + label: "", + message: "", + }), + "solana:11111111111111111111111111111111", + ); + + assert.throws(() => buildSolanaPayUri({ recipient: " " }), /Recipient is required/); +} + +function testAddressBookCore() { + const first = key(1); + const second = key(2); + assert.equal(normalizeAddressBookPubkey("not a pubkey"), null); + assert.equal(normalizeAddressBookPubkey(` ${first} `), first); + + let entries = upsertAddressBookEntry([], first, "Alice", 1000); + assert.equal(entries.length, 1); + assert.equal(entries[0].label, "Alice"); + assert.equal(entries[0].count, 1); + + entries = upsertAddressBookEntry(entries, first, undefined, 2000); + assert.equal(entries.length, 1); + assert.equal(entries[0].label, "Alice"); + assert.equal(entries[0].count, 2); + assert.equal(entries[0].lastUsed, 2000); + + entries = upsertAddressBookEntry(entries, second, "Bob", 1500); + assert.equal(entries.map((entry) => entry.pubkey).join(","), `${first},${second}`); + + entries = updateAddressBookEntryLabel(entries, first, ""); + assert.match(entries[0].label, /^.{4}\.\.\..{4}$/); + + entries = removeAddressBookEntry(entries, first); + assert.deepEqual(entries.map((entry) => entry.pubkey), [second]); + + const noisy = normalizeAddressBookEntries([ + { pubkey: "bad", label: "bad", lastUsed: 999, count: 10 }, + { pubkey: second, label: "old", lastUsed: 1, count: 1 }, + { pubkey: second, label: "new", lastUsed: 2, count: 3 }, + ]); + assert.equal(noisy.length, 1); + assert.equal(noisy[0].label, "new"); + + let capped = []; + for (let i = 1; i <= MAX_ADDRESS_BOOK_RECIPIENTS + 5; i += 1) { + capped = upsertAddressBookEntry(capped, key(i), `entry ${i}`, i); + } + assert.equal(capped.length, MAX_ADDRESS_BOOK_RECIPIENTS); + assert.equal(capped[0].label, `entry ${MAX_ADDRESS_BOOK_RECIPIENTS + 5}`); +} + +testSolanaPayUri(); +testAddressBookCore(); +console.log("Tier 0 service checks passed"); diff --git a/mobile_app/scripts/validate-tier0.sh b/mobile_app/scripts/validate-tier0.sh index 301baca1..be518a55 100755 --- a/mobile_app/scripts/validate-tier0.sh +++ b/mobile_app/scripts/validate-tier0.sh @@ -19,6 +19,9 @@ npm run lint section "Expo dependency check" npx expo install --check +section "Tier 0 service checks" +npm run validate:tier0:services + section "Fake money-state scan" if rg -n "sim_xxx|Demo transfer|fake success|simulated success|simulated transfer" app components src; then printf '\nFound forbidden fake transaction wording.\n' >&2 diff --git a/mobile_app/src/services/addressBook.ts b/mobile_app/src/services/addressBook.ts index 6913553a..6579bbb3 100644 --- a/mobile_app/src/services/addressBook.ts +++ b/mobile_app/src/services/addressBook.ts @@ -1,52 +1,16 @@ -import { PublicKey } from "@solana/web3.js"; import { useCallback, useEffect, useState } from "react"; +import { + type AddressBookEntry, + normalizeAddressBookEntries, + removeAddressBookEntry, + sortAndCapAddressBookEntries, + updateAddressBookEntryLabel, + upsertAddressBookEntry, +} from "@/src/services/addressBookCore"; import { SecureKeys, secureGet, secureSet } from "@/src/storage"; -export interface AddressBookEntry { - label: string; - pubkey: string; - lastUsed: number; - count: number; -} - -const MAX_RECIPIENTS = 50; - -function shortAddress(addr: string): string { - return `${addr.slice(0, 4)}...${addr.slice(-4)}`; -} - -function normalizePubkey(pubkey: string): string | null { - try { - return new PublicKey(pubkey.trim()).toBase58(); - } catch { - return null; - } -} - -function normalizeEntry(entry: Partial): AddressBookEntry | null { - if (typeof entry.pubkey !== "string") return null; - const pubkey = normalizePubkey(entry.pubkey); - if (!pubkey) return null; - - const count = Number.isFinite(entry.count) && Number(entry.count) > 0 - ? Math.floor(Number(entry.count)) - : 1; - const lastUsed = Number.isFinite(entry.lastUsed) && Number(entry.lastUsed) > 0 - ? Number(entry.lastUsed) - : Date.now(); - const label = typeof entry.label === "string" && entry.label.trim() - ? entry.label.trim().slice(0, 48) - : shortAddress(pubkey); - - return { label, pubkey, lastUsed, count }; -} - -function sortAndCap(entries: AddressBookEntry[]): AddressBookEntry[] { - return [...entries] - .sort((a, b) => b.lastUsed - a.lastUsed) - .slice(0, MAX_RECIPIENTS); -} +export type { AddressBookEntry } from "@/src/services/addressBookCore"; export async function readAddressBook(): Promise { const raw = await secureGet(SecureKeys.ADDRESS_BOOK); @@ -55,68 +19,35 @@ export async function readAddressBook(): Promise { try { const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; - const deduped = new Map(); - for (const item of parsed) { - const entry = normalizeEntry(item as Partial); - if (!entry) continue; - const existing = deduped.get(entry.pubkey); - if (!existing || entry.lastUsed > existing.lastUsed) { - deduped.set(entry.pubkey, entry); - } - } - return sortAndCap([...deduped.values()]); + return normalizeAddressBookEntries(parsed); } catch { return []; } } export async function writeAddressBook(entries: AddressBookEntry[]): Promise { - await secureSet(SecureKeys.ADDRESS_BOOK, JSON.stringify(sortAndCap(entries))); + await secureSet(SecureKeys.ADDRESS_BOOK, JSON.stringify(sortAndCapAddressBookEntries(entries))); } export async function saveAddressBookRecipient(pubkey: string, label?: string): Promise { - const normalized = normalizePubkey(pubkey); - if (!normalized) return readAddressBook(); - - const now = Date.now(); const entries = await readAddressBook(); - const existing = entries.find((entry) => entry.pubkey === normalized); - const nextEntry: AddressBookEntry = { - label: label?.trim() || existing?.label || shortAddress(normalized), - pubkey: normalized, - lastUsed: now, - count: (existing?.count ?? 0) + 1, - }; - - const next = [nextEntry, ...entries.filter((entry) => entry.pubkey !== normalized)]; + const next = upsertAddressBookEntry(entries, pubkey, label); await writeAddressBook(next); - return sortAndCap(next); + return next; } export async function updateAddressBookRecipient( pubkey: string, label: string, ): Promise { - const normalized = normalizePubkey(pubkey); - if (!normalized) return readAddressBook(); - const entries = await readAddressBook(); - const existing = entries.find((entry) => entry.pubkey === normalized); - if (!existing) return entries; - - const nextLabel = label.trim().slice(0, 48) || shortAddress(normalized); - const next = entries.map((entry) => - entry.pubkey === normalized ? { ...entry, label: nextLabel } : entry, - ); + const next = updateAddressBookEntryLabel(entries, pubkey, label); await writeAddressBook(next); - return sortAndCap(next); + return next; } export async function deleteAddressBookRecipient(pubkey: string): Promise { - const normalized = normalizePubkey(pubkey); - if (!normalized) return readAddressBook(); - - const next = (await readAddressBook()).filter((entry) => entry.pubkey !== normalized); + const next = removeAddressBookEntry(await readAddressBook(), pubkey); await writeAddressBook(next); return next; } diff --git a/mobile_app/src/services/addressBookCore.ts b/mobile_app/src/services/addressBookCore.ts new file mode 100644 index 00000000..56e0caf3 --- /dev/null +++ b/mobile_app/src/services/addressBookCore.ts @@ -0,0 +1,105 @@ +import { PublicKey } from "@solana/web3.js"; + +export interface AddressBookEntry { + label: string; + pubkey: string; + lastUsed: number; + count: number; +} + +export const MAX_ADDRESS_BOOK_RECIPIENTS = 50; + +function shortAddress(addr: string): string { + return `${addr.slice(0, 4)}...${addr.slice(-4)}`; +} + +export function normalizeAddressBookPubkey(pubkey: string): string | null { + try { + return new PublicKey(pubkey.trim()).toBase58(); + } catch { + return null; + } +} + +export function normalizeAddressBookEntry(entry: Partial): AddressBookEntry | null { + if (typeof entry.pubkey !== "string") return null; + const pubkey = normalizeAddressBookPubkey(entry.pubkey); + if (!pubkey) return null; + + const count = Number.isFinite(entry.count) && Number(entry.count) > 0 + ? Math.floor(Number(entry.count)) + : 1; + const lastUsed = Number.isFinite(entry.lastUsed) && Number(entry.lastUsed) > 0 + ? Number(entry.lastUsed) + : Date.now(); + const label = typeof entry.label === "string" && entry.label.trim() + ? entry.label.trim().slice(0, 48) + : shortAddress(pubkey); + + return { label, pubkey, lastUsed, count }; +} + +export function sortAndCapAddressBookEntries(entries: AddressBookEntry[]): AddressBookEntry[] { + return [...entries] + .sort((a, b) => b.lastUsed - a.lastUsed) + .slice(0, MAX_ADDRESS_BOOK_RECIPIENTS); +} + +export function normalizeAddressBookEntries(items: unknown[]): AddressBookEntry[] { + const deduped = new Map(); + for (const item of items) { + const entry = normalizeAddressBookEntry(item as Partial); + if (!entry) continue; + const existing = deduped.get(entry.pubkey); + if (!existing || entry.lastUsed > existing.lastUsed) { + deduped.set(entry.pubkey, entry); + } + } + return sortAndCapAddressBookEntries([...deduped.values()]); +} + +export function upsertAddressBookEntry( + entries: AddressBookEntry[], + pubkey: string, + label: string | undefined, + now: number = Date.now(), +): AddressBookEntry[] { + const normalized = normalizeAddressBookPubkey(pubkey); + if (!normalized) return sortAndCapAddressBookEntries(entries); + + const existing = entries.find((entry) => entry.pubkey === normalized); + const nextEntry: AddressBookEntry = { + label: label?.trim() || existing?.label || shortAddress(normalized), + pubkey: normalized, + lastUsed: now, + count: (existing?.count ?? 0) + 1, + }; + + return sortAndCapAddressBookEntries([ + nextEntry, + ...entries.filter((entry) => entry.pubkey !== normalized), + ]); +} + +export function updateAddressBookEntryLabel( + entries: AddressBookEntry[], + pubkey: string, + label: string, +): AddressBookEntry[] { + const normalized = normalizeAddressBookPubkey(pubkey); + if (!normalized) return sortAndCapAddressBookEntries(entries); + + const existing = entries.find((entry) => entry.pubkey === normalized); + if (!existing) return sortAndCapAddressBookEntries(entries); + + const nextLabel = label.trim().slice(0, 48) || shortAddress(normalized); + return sortAndCapAddressBookEntries(entries.map((entry) => + entry.pubkey === normalized ? { ...entry, label: nextLabel } : entry, + )); +} + +export function removeAddressBookEntry(entries: AddressBookEntry[], pubkey: string): AddressBookEntry[] { + const normalized = normalizeAddressBookPubkey(pubkey); + if (!normalized) return sortAndCapAddressBookEntries(entries); + return sortAndCapAddressBookEntries(entries.filter((entry) => entry.pubkey !== normalized)); +} From 7e903aaff61da67b613628520b7b009d3e7c02d2 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 19:51:35 -0800 Subject: [PATCH 15/30] fix(runtime): load solana polyfills before route modules --- mobile_app/screens/MessagesScreen.tsx | 2 ++ mobile_app/src/hooks/useNetworkMode.ts | 2 ++ mobile_app/src/infrastructure/network/MeshRpcAdapter.ts | 2 ++ mobile_app/src/infrastructure/wallet/LocalWallet.ts | 2 ++ mobile_app/src/infrastructure/wallet/MWAWallet.ts | 2 ++ mobile_app/src/services/sendTransaction.ts | 2 ++ 6 files changed, 12 insertions(+) diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index 36e57c54..a530c69d 100644 --- a/mobile_app/screens/MessagesScreen.tsx +++ b/mobile_app/screens/MessagesScreen.tsx @@ -1,3 +1,5 @@ +import "@/polyfills"; + import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useFocusEffect } from 'expo-router'; import { diff --git a/mobile_app/src/hooks/useNetworkMode.ts b/mobile_app/src/hooks/useNetworkMode.ts index 7e7019bf..684b7b31 100644 --- a/mobile_app/src/hooks/useNetworkMode.ts +++ b/mobile_app/src/hooks/useNetworkMode.ts @@ -1,3 +1,5 @@ +import "@/polyfills"; + import NetInfo from '@react-native-community/netinfo'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useLxmfContext } from '@/context/LxmfContext'; diff --git a/mobile_app/src/infrastructure/network/MeshRpcAdapter.ts b/mobile_app/src/infrastructure/network/MeshRpcAdapter.ts index e276169e..14eb22c8 100644 --- a/mobile_app/src/infrastructure/network/MeshRpcAdapter.ts +++ b/mobile_app/src/infrastructure/network/MeshRpcAdapter.ts @@ -1,3 +1,5 @@ +import "@/polyfills"; + import { LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js'; import { Buffer } from 'buffer'; import type { IRpcAdapter, MeshRpcRequest, MeshRpcResponse } from './types'; diff --git a/mobile_app/src/infrastructure/wallet/LocalWallet.ts b/mobile_app/src/infrastructure/wallet/LocalWallet.ts index 4c65595a..b07b89e6 100644 --- a/mobile_app/src/infrastructure/wallet/LocalWallet.ts +++ b/mobile_app/src/infrastructure/wallet/LocalWallet.ts @@ -1,3 +1,5 @@ +import "@/polyfills"; + import { gcm } from '@noble/ciphers/aes.js'; import { Keypair, PublicKey } from '@solana/web3.js'; import * as LocalAuthentication from 'expo-local-authentication'; diff --git a/mobile_app/src/infrastructure/wallet/MWAWallet.ts b/mobile_app/src/infrastructure/wallet/MWAWallet.ts index 0faac79e..d79ca31f 100644 --- a/mobile_app/src/infrastructure/wallet/MWAWallet.ts +++ b/mobile_app/src/infrastructure/wallet/MWAWallet.ts @@ -1,3 +1,5 @@ +import "@/polyfills"; + import { PublicKey } from '@solana/web3.js'; import { transact } from '@solana-mobile/mobile-wallet-adapter-protocol-web3js'; import { SecureKeys, secureGet, secureSet, secureDelete } from '@/src/storage'; diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 74f5373b..2a6bbc58 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -1,3 +1,5 @@ +import "@/polyfills"; + import { Connection, Keypair, From cb09e81799209948e0bf162aa9af19e1218ffce9 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 19:56:34 -0800 Subject: [PATCH 16/30] fix(security): keep recovery modal inside secure window --- .../components/settings/ExportWalletModal.tsx | 165 +++++++++--------- 1 file changed, 82 insertions(+), 83 deletions(-) diff --git a/mobile_app/components/settings/ExportWalletModal.tsx b/mobile_app/components/settings/ExportWalletModal.tsx index 9e482b37..1cdc1711 100644 --- a/mobile_app/components/settings/ExportWalletModal.tsx +++ b/mobile_app/components/settings/ExportWalletModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { View, Text, Pressable, Modal, StyleSheet, Animated } from 'react-native'; +import { View, Text, Pressable, StyleSheet, Animated } from 'react-native'; import { Feather } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; import * as ScreenCapture from 'expo-screen-capture'; @@ -58,99 +58,98 @@ export function ExportWalletModal({ onClose }: { onClose: () => void }) { const masked = secretKey ? '·'.repeat(secretKey.length) : ''; return ( - - - - - - - - - - - - EXPORT WALLET - - {walletMode === 'mwa' ? 'not available' : 'recovery key'} + + + + + + + + + + + EXPORT WALLET + + {walletMode === 'mwa' ? 'not available' : 'recovery key'} + + + + + + + + {walletMode === 'mwa' ? ( + + + + + + MWA wallet + + Keys are secured by your Solana Mobile device.{'\n'}Private key export is not available. - - + + CLOSE - - {walletMode === 'mwa' ? ( - - - - - - MWA wallet - - Keys are secured by your Solana Mobile device.{'\n'}Private key export is not available. - - - - CLOSE - + ) : ( + + + + + No mnemonic exists for this wallet. This base58 recovery key controls the wallet; store it offline only. + - ) : ( - - - - - No mnemonic exists for this wallet. This base58 recovery key controls the wallet; store it offline only. + + setRevealed(true)} + onRevealOut={() => setRevealed(false)} + onCopy={copyKey} + /> + + {!!secretKey && ( + <> + + HOLD TO REVEAL · SCREENSHOTS BLOCKED · BASE58 ENCODED - - - setRevealed(true)} - onRevealOut={() => setRevealed(false)} - onCopy={copyKey} - /> - - {!!secretKey && ( - <> - - HOLD TO REVEAL · SCREENSHOTS BLOCKED · BASE58 ENCODED + setCopiedAck(v => !v)} + style={[S.ackRow, { borderColor: copiedAck ? colors.primary + '66' : colors.border, backgroundColor: colors.surface1 }]} + > + + + I copied this recovery key - setCopiedAck(v => !v)} - style={[S.ackRow, { borderColor: copiedAck ? colors.primary + '66' : colors.border, backgroundColor: colors.surface1 }]} - > - - - I copied this recovery key - - - - )} - - - DONE - - - )} - - - + + + )} + + + DONE + + + )} + + ); } const S = StyleSheet.create({ + overlayRoot: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, zIndex: 20 }, sheet: { position: 'absolute', bottom: 0, left: 0, right: 0, borderRadius: 20, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, padding: 14, paddingBottom: 28, borderWidth: 0.5 }, grab: { width: 36, height: 4, borderRadius: 99, alignSelf: 'center', marginBottom: 14 }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }, From 02ec17916cc7597f361887e0d0577534b874d674 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 20:05:25 -0800 Subject: [PATCH 17/30] fix(android): declare and centralize ble permissions --- mobile_app/app.json | 9 +++++++++ .../plugins/withAndroidForegroundService.js | 7 +++++++ mobile_app/screens/NodesScreen.tsx | 16 ++++------------ 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/mobile_app/app.json b/mobile_app/app.json index 60a422d5..77020462 100644 --- a/mobile_app/app.json +++ b/mobile_app/app.json @@ -31,6 +31,15 @@ }, "android": { "package": "magicred1.anonmesh.app", + "permissions": [ + "android.permission.BLUETOOTH", + "android.permission.BLUETOOTH_ADMIN", + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_ADVERTISE", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION" + ], "adaptiveIcon": { "backgroundColor": "#0E0E12", "foregroundImage": "./assets/images/favicon.png" diff --git a/mobile_app/plugins/withAndroidForegroundService.js b/mobile_app/plugins/withAndroidForegroundService.js index 0b4175c2..07554deb 100644 --- a/mobile_app/plugins/withAndroidForegroundService.js +++ b/mobile_app/plugins/withAndroidForegroundService.js @@ -128,6 +128,13 @@ function withAndroidForegroundService(config) { if (!perms.some((p) => p.$['android:name'] === name)) perms.push({ $: { 'android:name': name } }); }; + addPerm('android.permission.BLUETOOTH'); + addPerm('android.permission.BLUETOOTH_ADMIN'); + addPerm('android.permission.BLUETOOTH_SCAN'); + addPerm('android.permission.BLUETOOTH_CONNECT'); + addPerm('android.permission.BLUETOOTH_ADVERTISE'); + addPerm('android.permission.ACCESS_FINE_LOCATION'); + addPerm('android.permission.ACCESS_COARSE_LOCATION'); addPerm('android.permission.FOREGROUND_SERVICE'); addPerm('android.permission.FOREGROUND_SERVICE_DATA_SYNC'); diff --git a/mobile_app/screens/NodesScreen.tsx b/mobile_app/screens/NodesScreen.tsx index 65742791..e6d969c9 100644 --- a/mobile_app/screens/NodesScreen.tsx +++ b/mobile_app/screens/NodesScreen.tsx @@ -1,7 +1,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { View, Text, ScrollView, Pressable, StyleSheet, - Platform, PermissionsAndroid, InteractionManager, + InteractionManager, } from 'react-native'; import { useFocusEffect } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -14,6 +14,7 @@ import { PendingCosigns, type PendingCosign } from '@/components/nodes/PendingCo import { PulseDot } from '@/components/ui/PulseDot'; import { NODES, FILTERS } from '@/components/nodes/constants'; import type { NodeData, Filter } from '@/components/nodes/types'; +import { requestBLEPermissions } from '@/src/utils/blePermissions'; // Converts a peer to NodeData WITHOUT latency — stable identity for MeshMap. // Latency is added separately for the list so MeshMap topology doesn't re-layout on every timer tick. @@ -54,17 +55,8 @@ export default function NodesScreen() { const enableBle = useCallback(async () => { if (bleActive) return; // already started — don't re-trigger GATT registration - if (Platform.OS === 'android') { - const perms = Platform.Version >= 31 - ? [ - PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, - PermissionsAndroid.PERMISSIONS.BLUETOOTH_ADVERTISE, - PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, - ] - : [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION]; - const results = await PermissionsAndroid.requestMultiple(perms); - if (Object.values(results).some(r => r !== PermissionsAndroid.RESULTS.GRANTED)) return; - } + const permissionStatus = await requestBLEPermissions(); + if (permissionStatus !== 'granted' && permissionStatus !== 'not_required') return; startBLE(); }, [bleActive, startBLE]); From 4db735d6c7e1d90897b114ad173cf7d95af52e8f Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 20:08:20 -0800 Subject: [PATCH 18/30] fix(settings): harden recovery key display --- mobile_app/components/settings/KeyBox.tsx | 7 ++++--- mobile_app/scripts/validate-tier0-services.mjs | 12 ++++++++++++ mobile_app/src/utils/recoveryKey.ts | 5 +++++ 3 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 mobile_app/src/utils/recoveryKey.ts diff --git a/mobile_app/components/settings/KeyBox.tsx b/mobile_app/components/settings/KeyBox.tsx index 5f6c633b..409abfe9 100644 --- a/mobile_app/components/settings/KeyBox.tsx +++ b/mobile_app/components/settings/KeyBox.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { View, Text, Pressable, StyleSheet } from 'react-native'; import { Feather } from '@expo/vector-icons'; import { fontFamily, useTheme } from '@/theme'; +import { formatRecoveryKey } from '@/src/utils/recoveryKey'; export interface KeyBoxProps { loading: boolean; @@ -35,7 +36,7 @@ export function KeyBox({ - {revealed ? secretKey : masked.slice(0, 44) + '\n' + masked.slice(44)} + {revealed ? formatRecoveryKey(secretKey) : formatRecoveryKey(masked)} @@ -52,7 +53,7 @@ export function KeyBox({ return ( - KEY NOT FOUND{'\n'}Sign out and recreate wallet to fix + KEY UNAVAILABLE{'\n'}Try again when ready ); @@ -68,7 +69,7 @@ export function KeyBox({ const S = StyleSheet.create({ box: { padding: 14, borderRadius: 14, borderWidth: 0.5, alignItems: 'center' }, - key: { fontFamily: fontFamily.mono, fontSize: 11, lineHeight: 20 }, + key: { fontFamily: fontFamily.mono, fontSize: 11, lineHeight: 20, textAlign: 'center' }, hint: { fontFamily: fontFamily.sansMd, fontSize: 10, letterSpacing: 1 }, copyBtn: { flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 10, paddingHorizontal: 12, paddingVertical: 7, borderRadius: 8, borderWidth: 0.5 }, }); diff --git a/mobile_app/scripts/validate-tier0-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs index ebf6ef2d..b3407667 100644 --- a/mobile_app/scripts/validate-tier0-services.mjs +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -10,6 +10,7 @@ const { updateAddressBookEntryLabel, upsertAddressBookEntry, } = await import("../src/services/addressBookCore.ts"); +const { formatRecoveryKey } = await import("../src/utils/recoveryKey.ts"); function key(index) { const seed = new Uint8Array(32); @@ -84,6 +85,17 @@ function testAddressBookCore() { assert.equal(capped[0].label, `entry ${MAX_ADDRESS_BOOK_RECIPIENTS + 5}`); } +function testRecoveryKeyFormatting() { + assert.equal(formatRecoveryKey(""), ""); + assert.equal(formatRecoveryKey("1234567890", 4), "1234\n5678\n90"); + assert.equal( + formatRecoveryKey("111111111111111111111122222222222222222222223333", 22), + "1111111111111111111111\n2222222222222222222222\n3333", + ); + assert.equal(formatRecoveryKey("abc", 0), "a\nb\nc"); +} + testSolanaPayUri(); testAddressBookCore(); +testRecoveryKeyFormatting(); console.log("Tier 0 service checks passed"); diff --git a/mobile_app/src/utils/recoveryKey.ts b/mobile_app/src/utils/recoveryKey.ts new file mode 100644 index 00000000..5bff2c99 --- /dev/null +++ b/mobile_app/src/utils/recoveryKey.ts @@ -0,0 +1,5 @@ +export function formatRecoveryKey(value: string, chunkSize = 22): string { + const normalizedChunkSize = Math.max(1, Math.floor(chunkSize)); + const chunks = value.match(new RegExp(`.{1,${normalizedChunkSize}}`, 'g')) ?? []; + return chunks.join('\n'); +} From c2f6d18434b18e7b10b669f68e3195116e9d64e3 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 20:15:32 -0800 Subject: [PATCH 19/30] chore(test): verify tier0 config gates --- mobile_app/scripts/validate-tier0-config.mjs | 55 ++++++++++++++++++++ mobile_app/scripts/validate-tier0.sh | 3 ++ 2 files changed, 58 insertions(+) create mode 100644 mobile_app/scripts/validate-tier0-config.mjs diff --git a/mobile_app/scripts/validate-tier0-config.mjs b/mobile_app/scripts/validate-tier0-config.mjs new file mode 100644 index 00000000..40467fe8 --- /dev/null +++ b/mobile_app/scripts/validate-tier0-config.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); + +const requiredAndroidPermissions = [ + "android.permission.BLUETOOTH", + "android.permission.BLUETOOTH_ADMIN", + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_ADVERTISE", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", +]; + +function readText(relativePath) { + return fs.readFileSync(path.join(root, relativePath), "utf8"); +} + +function testAppJsonPermissions() { + const appConfig = JSON.parse(readText("app.json")); + const permissions = appConfig.expo?.android?.permissions ?? []; + for (const permission of requiredAndroidPermissions) { + assert.ok( + permissions.includes(permission), + `app.json android.permissions is missing ${permission}`, + ); + } +} + +function testForegroundServicePluginPermissions() { + const pluginSource = readText("plugins/withAndroidForegroundService.js"); + for (const permission of requiredAndroidPermissions) { + assert.match( + pluginSource, + new RegExp(`addPerm\\(['"]${permission.replaceAll(".", "\\.")}['"]\\)`), + `withAndroidForegroundService.js is missing addPerm('${permission}')`, + ); + } + assert.match(pluginSource, /FOREGROUND_SERVICE_TYPE_DATA_SYNC/); + assert.match(pluginSource, /'android:foregroundServiceType': 'dataSync'/); +} + +function testEnvExample() { + const envExample = readText(".env.example"); + assert.match(envExample, /^EXPO_PUBLIC_DEMO_MODE=false$/m); + assert.match(envExample, /^EXPO_PUBLIC_SOLANA_RPC=$/m); + assert.match(envExample, /EXPO_PUBLIC_ prefix are inlined at build time/); +} + +testAppJsonPermissions(); +testForegroundServicePluginPermissions(); +testEnvExample(); +console.log("Tier 0 config checks passed"); diff --git a/mobile_app/scripts/validate-tier0.sh b/mobile_app/scripts/validate-tier0.sh index be518a55..4df7d74a 100755 --- a/mobile_app/scripts/validate-tier0.sh +++ b/mobile_app/scripts/validate-tier0.sh @@ -22,6 +22,9 @@ npx expo install --check section "Tier 0 service checks" npm run validate:tier0:services +section "Tier 0 config checks" +node ./scripts/validate-tier0-config.mjs + section "Fake money-state scan" if rg -n "sim_xxx|Demo transfer|fake success|simulated success|simulated transfer" app components src; then printf '\nFound forbidden fake transaction wording.\n' >&2 From 65288a1f67f7f1bf18c3d8c4f5c19cedc971b152 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 20:19:48 -0800 Subject: [PATCH 20/30] fix(send): parse sol amounts exactly --- mobile_app/components/send/ReviewCard.tsx | 4 +-- .../scripts/validate-tier0-services.mjs | 12 +++++++ mobile_app/src/services/sendTransaction.ts | 35 ++++--------------- mobile_app/src/utils/amount.ts | 18 ++++++++++ 4 files changed, 38 insertions(+), 31 deletions(-) create mode 100644 mobile_app/src/utils/amount.ts diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index 397d6953..f1d506c7 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -141,7 +141,7 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review estimateSolTransferFeeLamports({ walletAdapter: wallet, recipientAddress: to, - amountSOL: Number.parseFloat(amount), + amountSOL: amount, }), FEE_ESTIMATE_TIMEOUT_MS, ) @@ -204,7 +204,7 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review walletAdapter: wallet, rpcAdapter, recipientAddress: to, - amountSOL: Number.parseFloat(amount), + amountSOL: amount, }) : await sendSplTransfer({ walletAdapter: wallet, diff --git a/mobile_app/scripts/validate-tier0-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs index b3407667..1fa55f97 100644 --- a/mobile_app/scripts/validate-tier0-services.mjs +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -11,6 +11,7 @@ const { upsertAddressBookEntry, } = await import("../src/services/addressBookCore.ts"); const { formatRecoveryKey } = await import("../src/utils/recoveryKey.ts"); +const { parseBaseUnits } = await import("../src/utils/amount.ts"); function key(index) { const seed = new Uint8Array(32); @@ -95,7 +96,18 @@ function testRecoveryKeyFormatting() { assert.equal(formatRecoveryKey("abc", 0), "a\nb\nc"); } +function testBaseUnitParsing() { + assert.equal(parseBaseUnits("1", 9), 1_000_000_000n); + assert.equal(parseBaseUnits("0.000000001", 9), 1n); + assert.equal(parseBaseUnits("001.2300", 6), 1_230_000n); + assert.throws(() => parseBaseUnits("1abc", 9), /Invalid amount/); + assert.throws(() => parseBaseUnits("1.0000000001", 9), /Too many decimal places/); + assert.throws(() => parseBaseUnits("0", 9), /Invalid amount/); + assert.throws(() => parseBaseUnits("-1", 9), /Invalid amount/); +} + testSolanaPayUri(); testAddressBookCore(); testRecoveryKeyFormatting(); +testBaseUnitParsing(); console.log("Tier 0 service checks passed"); diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 2a6bbc58..1db8d0ce 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -3,7 +3,6 @@ import "@/polyfills"; import { Connection, Keypair, - LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction, @@ -19,6 +18,7 @@ import { Buffer } from "buffer"; import type { IRpcAdapter } from "@/src/infrastructure/network"; import type { IWalletAdapter } from "@/src/infrastructure/wallet"; import { SecureKeys, secureGet, secureSet } from "@/src/storage"; +import { parseBaseUnits } from "@/src/utils/amount"; const APP_IDENTITY = { name: "anonmesh", uri: "https://anonme.sh", @@ -41,7 +41,7 @@ export interface SendSolParams { walletAdapter: IWalletAdapter; rpcAdapter: IRpcAdapter; recipientAddress: string; - amountSOL: number; + amountSOL: string; } export interface SendSplParams { @@ -56,7 +56,7 @@ export interface SendSplParams { export interface EstimateSolTransferFeeParams { walletAdapter: IWalletAdapter; recipientAddress: string; - amountSOL: number; + amountSOL: string; } export interface EstimateSplTransferFeeParams { @@ -119,7 +119,7 @@ function buildSolTransferTransaction({ }: { fromPubkey: PublicKey; recipientAddress: string; - amountSOL: number; + amountSOL: string; }): Transaction { let toPubkey: PublicKey; try { @@ -128,35 +128,12 @@ function buildSolTransferTransaction({ throw new Error("Invalid recipient address"); } - const lamports = Math.round(amountSOL * LAMPORTS_PER_SOL); - if (!Number.isFinite(lamports) || lamports <= 0) { - throw new Error("Invalid amount"); - } - + const lamports = parseBaseUnits(amountSOL, 9); return new Transaction().add( SystemProgram.transfer({ fromPubkey, toPubkey, lamports }), ); } -function parseTokenUnits(amount: string, decimals: number): bigint { - const normalized = amount.trim(); - if (!/^\d+(\.\d+)?$/.test(normalized)) { - throw new Error("Invalid amount"); - } - - const [whole, fraction = ""] = normalized.split("."); - if (fraction.length > decimals) { - throw new Error(`Too many decimal places for this token`); - } - - const units = `${whole}${fraction.padEnd(decimals, "0")}`.replace(/^0+(?=\d)/, ""); - const value = BigInt(units || "0"); - if (value <= 0n) { - throw new Error("Invalid amount"); - } - return value; -} - async function buildSplTransferTransaction({ fromPubkey, recipientAddress, @@ -183,7 +160,7 @@ async function buildSplTransferTransaction({ throw new Error("Invalid token decimals"); } - const rawAmount = parseTokenUnits(amount, decimals); + const rawAmount = parseBaseUnits(amount, decimals); const fromAta = await getAssociatedTokenAddress(mint, fromPubkey); const toAta = await getAssociatedTokenAddress(mint, toOwner); diff --git a/mobile_app/src/utils/amount.ts b/mobile_app/src/utils/amount.ts new file mode 100644 index 00000000..a249d269 --- /dev/null +++ b/mobile_app/src/utils/amount.ts @@ -0,0 +1,18 @@ +export function parseBaseUnits(amount: string, decimals: number): bigint { + const normalized = amount.trim(); + if (!/^\d+(\.\d+)?$/.test(normalized)) { + throw new Error("Invalid amount"); + } + + const [whole, fraction = ""] = normalized.split("."); + if (fraction.length > decimals) { + throw new Error("Too many decimal places for this token"); + } + + const units = `${whole}${fraction.padEnd(decimals, "0")}`.replace(/^0+(?=\d)/, ""); + const value = BigInt(units || "0"); + if (value <= 0n) { + throw new Error("Invalid amount"); + } + return value; +} From 544b99d5bcc4cee6ae72e6846602bf17f0e65990 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 20:22:54 -0800 Subject: [PATCH 21/30] refactor(send): centralize devnet explorer links --- mobile_app/components/send/SuccessCard.tsx | 5 ++--- mobile_app/components/wallet/TxDetailModal.tsx | 7 ++----- mobile_app/scripts/validate-tier0-services.mjs | 9 +++++++++ mobile_app/src/services/explorer.ts | 3 +++ mobile_app/src/services/sendTransaction.ts | 9 +++------ 5 files changed, 19 insertions(+), 14 deletions(-) create mode 100644 mobile_app/src/services/explorer.ts diff --git a/mobile_app/components/send/SuccessCard.tsx b/mobile_app/components/send/SuccessCard.tsx index 594a213a..f7baea8a 100644 --- a/mobile_app/components/send/SuccessCard.tsx +++ b/mobile_app/components/send/SuccessCard.tsx @@ -14,6 +14,7 @@ import { DepthButton, Icon, Pill } from "@/components/primitives"; import { SendScaffold } from "@/components/send/SendScaffold"; import * as haptics from "@/src/design-system/haptics"; import { useGlass } from "@/hooks/useGlass"; +import { buildDevnetExplorerTxUrl } from "@/src/services/explorer"; import { useTheme } from "@/theme"; function shortReference(id: string) { @@ -60,9 +61,7 @@ export function SuccessCard({ txId, amount, symbol }: SuccessCardProps) { function handleExplorer() { haptics.tap(); - const encodedTxId = encodeURIComponent(txId); - const url = `https://explorer.solana.com/tx/${encodedTxId}?cluster=devnet`; - Linking.openURL(url).catch(() => undefined); + Linking.openURL(buildDevnetExplorerTxUrl(txId)).catch(() => undefined); } return ( diff --git a/mobile_app/components/wallet/TxDetailModal.tsx b/mobile_app/components/wallet/TxDetailModal.tsx index 2fed9d67..d356735e 100644 --- a/mobile_app/components/wallet/TxDetailModal.tsx +++ b/mobile_app/components/wallet/TxDetailModal.tsx @@ -7,6 +7,7 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { DepthButton, Icon, Pill } from "@/components/primitives"; import type { ActivityEntry } from "@/src/services/walletData"; import * as haptics from "@/src/design-system/haptics"; +import { buildDevnetExplorerTxUrl } from "@/src/services/explorer"; import { fontFamily as FF, useTheme } from "@/theme"; interface TxDetailModalProps { @@ -34,10 +35,6 @@ function formatAmount(tx: ActivityEntry): string { return `${sign}${amount} ${tx.symbol}`; } -function explorerUrl(signature: string): string { - return `https://explorer.solana.com/tx/${encodeURIComponent(signature)}?cluster=devnet`; -} - function DetailRow({ label, value, @@ -81,7 +78,7 @@ export function TxDetailModal({ tx, visible, onClose }: TxDetailModalProps) { async function handleExplorer() { haptics.tap(); - await WebBrowser.openBrowserAsync(explorerUrl(activeTx.signature)); + await WebBrowser.openBrowserAsync(buildDevnetExplorerTxUrl(activeTx.signature)); } return ( diff --git a/mobile_app/scripts/validate-tier0-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs index 1fa55f97..2b8757e9 100644 --- a/mobile_app/scripts/validate-tier0-services.mjs +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -12,6 +12,7 @@ const { } = await import("../src/services/addressBookCore.ts"); const { formatRecoveryKey } = await import("../src/utils/recoveryKey.ts"); const { parseBaseUnits } = await import("../src/utils/amount.ts"); +const { buildDevnetExplorerTxUrl } = await import("../src/services/explorer.ts"); function key(index) { const seed = new Uint8Array(32); @@ -106,8 +107,16 @@ function testBaseUnitParsing() { assert.throws(() => parseBaseUnits("-1", 9), /Invalid amount/); } +function testExplorerUrls() { + assert.equal( + buildDevnetExplorerTxUrl("abc+/="), + "https://explorer.solana.com/tx/abc%2B%2F%3D?cluster=devnet", + ); +} + testSolanaPayUri(); testAddressBookCore(); testRecoveryKeyFormatting(); testBaseUnitParsing(); +testExplorerUrls(); console.log("Tier 0 service checks passed"); diff --git a/mobile_app/src/services/explorer.ts b/mobile_app/src/services/explorer.ts new file mode 100644 index 00000000..efc669ff --- /dev/null +++ b/mobile_app/src/services/explorer.ts @@ -0,0 +1,3 @@ +export function buildDevnetExplorerTxUrl(signature: string): string { + return `https://explorer.solana.com/tx/${encodeURIComponent(signature)}?cluster=devnet`; +} diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 1db8d0ce..26451f68 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -17,6 +17,7 @@ import { Buffer } from "buffer"; import type { IRpcAdapter } from "@/src/infrastructure/network"; import type { IWalletAdapter } from "@/src/infrastructure/wallet"; +import { buildDevnetExplorerTxUrl } from "@/src/services/explorer"; import { SecureKeys, secureGet, secureSet } from "@/src/storage"; import { parseBaseUnits } from "@/src/utils/amount"; const APP_IDENTITY = { @@ -84,10 +85,6 @@ interface MwaAuthResult { accounts: { address: string }[]; } -function explorerUrl(signature: string): string { - return `https://explorer.solana.com/tx/${encodeURIComponent(signature)}?cluster=devnet`; -} - function isWalletDenial(err: unknown): boolean { const msg = err instanceof Error ? err.message : String(err); const normalized = msg.toLowerCase(); @@ -210,7 +207,7 @@ async function signAndSubmitTransaction({ } finally { secretKey.fill(0); } - return { signature, explorerUrl: explorerUrl(signature) }; + return { signature, explorerUrl: buildDevnetExplorerTxUrl(signature) }; } const cachedToken = await secureGet(SecureKeys.MWA_TOKEN); @@ -242,7 +239,7 @@ async function signAndSubmitTransaction({ } const signature = await rpcAdapter.sendRawTransaction(signedTx.serialize()); - return { signature, explorerUrl: explorerUrl(signature) }; + return { signature, explorerUrl: buildDevnetExplorerTxUrl(signature) }; } export async function estimateSolTransferFeeLamports({ From 5e0e9b27388416b8f1455e123825290da4935edb Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 22:08:13 -0800 Subject: [PATCH 22/30] fix(tier0): log wallet export and label stealth preview --- mobile_app/app/receive.tsx | 4 +--- mobile_app/components/send/ReviewCard.tsx | 29 ++++++++++++++++------- mobile_app/context/WalletContext.tsx | 1 + 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/mobile_app/app/receive.tsx b/mobile_app/app/receive.tsx index 682ad855..f28c7cef 100644 --- a/mobile_app/app/receive.tsx +++ b/mobile_app/app/receive.tsx @@ -242,9 +242,7 @@ export default function ReceiveScreen() { {isStealth && ( - only works with{" "} - anonmesh - {" "}senders + preview only · not a spendable Solana address )} diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index f1d506c7..cfeef7a3 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -115,7 +115,6 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review const { wallet } = useWallet(); const { adapter: rpcAdapter, mode: networkMode } = useNetworkMode(); - const [stealthEnabled, setStealthEnabled] = useState(false); const [error, setError] = useState(null); const [feeLabel, setFeeLabel] = useState("Calculating..."); const [isConfirming, setIsConfirming] = useState(false); @@ -222,13 +221,20 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review params: { amount, symbol, txId: result.signature }, }); } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error("[send/ReviewCard] transfer failed", { + message, + symbol, + mintAddress: normalizedMint || null, + networkMode: rpcAdapter.mode, + }); setError( err instanceof TransactionNotApprovedError ? { kind: "approval", message: "Approve the transaction in your wallet to submit it.", } - : { kind: "send", message: err instanceof Error ? err.message : "Send failed" }, + : { kind: "send", message }, ); setSliderResetKey((k) => k + 1); } finally { @@ -323,24 +329,29 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review ) : null} - {/* Stealth toggle tile */} + {/* Stealth preview tile */} setStealthEnabled((s) => !s)} + onPress={() => { + setError({ + kind: "unsupported", + message: "Stealth transfer is a preview only. This send will use the standard devnet transfer path.", + }); + }} style={[S.tile, S.stealthTile, { backgroundColor: colors.surface1, borderColor: colors.border }]} > - - Stealth + + Stealth preview - + {/* Error */} diff --git a/mobile_app/context/WalletContext.tsx b/mobile_app/context/WalletContext.tsx index c00ce568..68679202 100644 --- a/mobile_app/context/WalletContext.tsx +++ b/mobile_app/context/WalletContext.tsx @@ -182,6 +182,7 @@ export function WalletProvider({ children, autoInitialize = true }: WalletProvid } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg === 'Authentication cancelled') return null; + console.error('[wallet/exportPrivateKey] failed:', msg, err); Alert.alert('Export failed', msg); return null; } From 1fdf353e2180c76f995fe58908abd4c958d59d6c Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 22:11:49 -0800 Subject: [PATCH 23/30] fix(wallet): recreate incomplete local wallet --- mobile_app/src/infrastructure/wallet/WalletFactory.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile_app/src/infrastructure/wallet/WalletFactory.ts b/mobile_app/src/infrastructure/wallet/WalletFactory.ts index 0f82a007..6799bc94 100644 --- a/mobile_app/src/infrastructure/wallet/WalletFactory.ts +++ b/mobile_app/src/infrastructure/wallet/WalletFactory.ts @@ -38,8 +38,13 @@ export const WalletFactory = { }, async createLocal(): Promise { - // Guard: reconnect if wallet already exists rather than overwriting keypair + // Guard: reconnect if wallet already exists rather than overwriting keypair. + // If storage is partial, the wallet cannot export or sign reliably; recreate it. if (await LocalWallet.exists()) { + if (!await LocalWallet.isFullyIntact()) { + await LocalWallet.delete(); + return LocalWallet.create(); + } const w = new LocalWallet(); await w.connect(); return w; From 398793b8a185b2382828cfdb3c5b42cc97dc82b5 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 23:17:54 -0800 Subject: [PATCH 24/30] fix(send): surface wallet transfer failures --- mobile_app/components/send/ReviewCard.tsx | 11 ++- .../components/settings/ExportWalletModal.tsx | 2 +- .../scripts/validate-tier0-services.mjs | 21 ++++++ mobile_app/src/services/sendTransaction.ts | 73 +++++++++++++------ mobile_app/src/utils/errors.ts | 71 ++++++++++++++++++ 5 files changed, 150 insertions(+), 28 deletions(-) create mode 100644 mobile_app/src/utils/errors.ts diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index cfeef7a3..a26be145 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -18,6 +18,7 @@ import { TransactionNotApprovedError, } from "@/src/services/sendTransaction"; import { DEMO_MODE } from "@/src/utils/demoMode"; +import { summarizeError } from "@/src/utils/errors"; import { fontFamily as FF, useTheme } from "@/theme"; function shortAddress(addr: string): string { @@ -221,9 +222,13 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review params: { amount, symbol, txId: result.signature }, }); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); + const summary = summarizeError(err, "Transaction failed before the wallet returned a reason"); console.error("[send/ReviewCard] transfer failed", { - message, + message: summary.message, + name: summary.name ?? null, + code: summary.code ?? null, + raw: summary.raw ?? null, + cause: summary.cause ?? null, symbol, mintAddress: normalizedMint || null, networkMode: rpcAdapter.mode, @@ -234,7 +239,7 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review kind: "approval", message: "Approve the transaction in your wallet to submit it.", } - : { kind: "send", message }, + : { kind: "send", message: summary.message }, ); setSliderResetKey((k) => k + 1); } finally { diff --git a/mobile_app/components/settings/ExportWalletModal.tsx b/mobile_app/components/settings/ExportWalletModal.tsx index 1cdc1711..151846d4 100644 --- a/mobile_app/components/settings/ExportWalletModal.tsx +++ b/mobile_app/components/settings/ExportWalletModal.tsx @@ -98,7 +98,7 @@ export function ExportWalletModal({ onClose }: { onClose: () => void }) { - No mnemonic exists for this wallet. This base58 recovery key controls the wallet; store it offline only. + This wallet does not use a 12- or 24-word seed phrase. The copied base58 recovery key is the backup; store it offline only. diff --git a/mobile_app/scripts/validate-tier0-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs index 2b8757e9..0e0c67a6 100644 --- a/mobile_app/scripts/validate-tier0-services.mjs +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -12,6 +12,7 @@ const { } = await import("../src/services/addressBookCore.ts"); const { formatRecoveryKey } = await import("../src/utils/recoveryKey.ts"); const { parseBaseUnits } = await import("../src/utils/amount.ts"); +const { summarizeError } = await import("../src/utils/errors.ts"); const { buildDevnetExplorerTxUrl } = await import("../src/services/explorer.ts"); function key(index) { @@ -114,9 +115,29 @@ function testExplorerUrls() { ); } +function testErrorSummaries() { + assert.equal( + summarizeError("", "fallback message").message, + "fallback message", + ); + assert.equal( + summarizeError({}, "fallback message").message, + "fallback message", + ); + assert.equal( + summarizeError({ code: -32002, error: "WalletBusy" }, "fallback message").message, + "WalletBusy -32002", + ); + assert.equal( + summarizeError(new Error("boom"), "fallback message").message, + "boom", + ); +} + testSolanaPayUri(); testAddressBookCore(); testRecoveryKeyFormatting(); testBaseUnitParsing(); testExplorerUrls(); +testErrorSummaries(); console.log("Tier 0 service checks passed"); diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 26451f68..1bc469b0 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -20,6 +20,7 @@ import type { IWalletAdapter } from "@/src/infrastructure/wallet"; import { buildDevnetExplorerTxUrl } from "@/src/services/explorer"; import { SecureKeys, secureGet, secureSet } from "@/src/storage"; import { parseBaseUnits } from "@/src/utils/amount"; +import { summarizeError } from "@/src/utils/errors"; const APP_IDENTITY = { name: "anonmesh", uri: "https://anonme.sh", @@ -86,8 +87,13 @@ interface MwaAuthResult { } function isWalletDenial(err: unknown): boolean { - const msg = err instanceof Error ? err.message : String(err); - const normalized = msg.toLowerCase(); + const summary = summarizeError(err, ""); + const normalized = [ + summary.message, + summary.name, + summary.code, + summary.raw, + ].filter(Boolean).join(" ").toLowerCase(); return ( normalized.includes("authentication cancelled") || normalized.includes("authorization request failed") || @@ -102,13 +108,28 @@ function isWalletDenial(err: unknown): boolean { ); } -function normalizeWalletError(err: unknown): never { +function normalizeWalletError(err: unknown, fallback?: string): never { if (isWalletDenial(err)) { throw new TransactionNotApprovedError(); } + if (fallback) { + throw new Error(summarizeError(err, fallback).message); + } throw err; } +async function submitSignedTransaction( + rpcAdapter: IRpcAdapter, + tx: Transaction, +): Promise { + try { + return await rpcAdapter.sendRawTransaction(tx.serialize()); + } catch (err: unknown) { + const summary = summarizeError(err, "RPC rejected the transaction without returning a reason"); + throw new Error(`Transaction submission failed: ${summary.message}`); + } +} + function buildSolTransferTransaction({ fromPubkey, recipientAddress, @@ -203,7 +224,7 @@ async function signAndSubmitTransaction({ try { const keypair = Keypair.fromSecretKey(secretKey); tx.sign(keypair); - signature = await rpcAdapter.sendRawTransaction(tx.serialize()); + signature = await submitSignedTransaction(rpcAdapter, tx); } finally { secretKey.fill(0); } @@ -212,33 +233,37 @@ async function signAndSubmitTransaction({ const cachedToken = await secureGet(SecureKeys.MWA_TOKEN); const signedTransactions: Transaction[] = []; - await transact(async (mwaWallet) => { - const auth = await mwaWallet.reauthorize({ - auth_token: cachedToken ?? "", - identity: APP_IDENTITY, + try { + await transact(async (mwaWallet) => { + const auth = await mwaWallet.reauthorize({ + auth_token: cachedToken ?? "", + identity: APP_IDENTITY, + }); + const nextToken = (auth as MwaAuthResult).auth_token; + if (nextToken) await secureSet(SecureKeys.MWA_TOKEN, nextToken); + + const sessionPubkey = new PublicKey(Buffer.from(auth.accounts[0].address, "base64")); + + if (sessionPubkey.toBase58() !== expectedPubkey.toBase58()) { + throw new Error( + `MWA account mismatch - expected ${expectedPubkey.toBase58().slice(0, 8)}..., wallet returned ${sessionPubkey.toBase58().slice(0, 8)}.... Reconnect the correct account.`, + ); + } + + tx.feePayer = sessionPubkey; + const signed = await mwaWallet.signTransactions({ transactions: [tx] }); + if (signed[0]) signedTransactions[0] = signed[0]; }); - const nextToken = (auth as MwaAuthResult).auth_token; - if (nextToken) await secureSet(SecureKeys.MWA_TOKEN, nextToken); - - const sessionPubkey = new PublicKey(Buffer.from(auth.accounts[0].address, "base64")); - - if (sessionPubkey.toBase58() !== expectedPubkey.toBase58()) { - throw new Error( - `MWA account mismatch — expected ${expectedPubkey.toBase58().slice(0, 8)}…, wallet returned ${sessionPubkey.toBase58().slice(0, 8)}…. Reconnect the correct account.`, - ); - } - - tx.feePayer = sessionPubkey; - const signed = await mwaWallet.signTransactions({ transactions: [tx] }); - if (signed[0]) signedTransactions[0] = signed[0]; - }); + } catch (err: unknown) { + normalizeWalletError(err, "Wallet signing failed before returning a reason"); + } const signedTx = signedTransactions[0]; if (!signedTx) { throw new TransactionNotApprovedError(); } - const signature = await rpcAdapter.sendRawTransaction(signedTx.serialize()); + const signature = await submitSignedTransaction(rpcAdapter, signedTx); return { signature, explorerUrl: buildDevnetExplorerTxUrl(signature) }; } diff --git a/mobile_app/src/utils/errors.ts b/mobile_app/src/utils/errors.ts new file mode 100644 index 00000000..3cd6d6a1 --- /dev/null +++ b/mobile_app/src/utils/errors.ts @@ -0,0 +1,71 @@ +export interface ErrorSummary { + message: string; + name?: string; + code?: string | number; + stack?: string; + raw?: string; + cause?: string; +} + +function readObjectField(obj: Record, key: string): unknown { + return obj[key]; +} + +function readStringField(obj: Record, key: string): string | undefined { + const value = readObjectField(obj, key); + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function safeStringify(value: unknown): string | undefined { + if (typeof value === "string") { + return value.trim().length > 0 ? value : undefined; + } + + try { + const serialized = JSON.stringify(value); + return serialized && serialized !== "{}" ? serialized : undefined; + } catch { + return undefined; + } +} + +export function summarizeError( + err: unknown, + fallback = "Unknown error", +): ErrorSummary { + if (err instanceof Error) { + const message = err.message.trim().length > 0 ? err.message : fallback; + const summary: ErrorSummary = { + message, + name: err.name, + stack: err.stack, + }; + if (err.cause) summary.cause = safeStringify(err.cause); + return summary; + } + + if (typeof err === "string") { + return { message: err.trim().length > 0 ? err : fallback }; + } + + if (err && typeof err === "object") { + const record = err as Record; + const message = readStringField(record, "message"); + const name = readStringField(record, "name") ?? readStringField(record, "error"); + const codeValue = readObjectField(record, "code"); + const code = + typeof codeValue === "string" || typeof codeValue === "number" ? codeValue : undefined; + const raw = safeStringify(err); + const parts = [name, code !== undefined ? String(code) : undefined].filter(Boolean); + + return { + message: message ?? (parts.length > 0 ? parts.join(" ") : fallback), + name, + code, + raw, + cause: safeStringify(readObjectField(record, "cause")), + }; + } + + return { message: fallback, raw: safeStringify(err) }; +} From 84b36ad3ab2370a38b02e3fa8ebc46c7c839dd30 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 6 May 2026 23:24:25 -0800 Subject: [PATCH 25/30] chore(tier0): add send log capture helper --- .../scripts/capture-tier0-send-logcat.sh | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100755 mobile_app/scripts/capture-tier0-send-logcat.sh diff --git a/mobile_app/scripts/capture-tier0-send-logcat.sh b/mobile_app/scripts/capture-tier0-send-logcat.sh new file mode 100755 index 00000000..6ad182ca --- /dev/null +++ b/mobile_app/scripts/capture-tier0-send-logcat.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOG_DIR="${LOG_DIR:-$ROOT_DIR/tmp/tier0-logs}" +STAMP="$(date +%Y%m%d-%H%M%S)" +OUT_FILE="${1:-$LOG_DIR/send-$STAMP.log}" + +mkdir -p "$(dirname "$OUT_FILE")" + +if ! adb get-state >/dev/null 2>&1; then + printf 'No adb device is connected. Connect Seeker, then retry.\n' >&2 + exit 1 +fi + +cat < recovery and reveal/copy. + +Stop capture with Ctrl-C after success or failure appears in-app. +EOF + +adb logcat -c +adb logcat -v time \ + ReactNativeJS:I AndroidRuntime:E anonmesh:I LxmfModule:I '*:S' \ + | tee "$OUT_FILE" From fa78c4e3c7e27597befafce4c29f042d5b60bb2f Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 7 May 2026 01:24:31 -0800 Subject: [PATCH 26/30] fix(errors): preserve Error.cause through safeStringify JSON.stringify on an Error instance returns "{}" because Error fields are not enumerable, so Error.cause was being silently dropped from summarizeError output. Send-failure toasts and structured logs lost the inner reason whenever the wrapping Error chained to another Error (common with rpc adapter wrappers). Match the string-input branch by formatting nested Errors as "Name: message" before falling back to JSON.stringify. --- mobile_app/src/utils/errors.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mobile_app/src/utils/errors.ts b/mobile_app/src/utils/errors.ts index 3cd6d6a1..c6bef89b 100644 --- a/mobile_app/src/utils/errors.ts +++ b/mobile_app/src/utils/errors.ts @@ -21,6 +21,11 @@ function safeStringify(value: unknown): string | undefined { return value.trim().length > 0 ? value : undefined; } + if (value instanceof Error) { + const parts = [value.name, value.message].filter(Boolean).join(": "); + return parts.length > 0 ? parts : undefined; + } + try { const serialized = JSON.stringify(value); return serialized && serialized !== "{}" ? serialized : undefined; From f0a7202bfabe49640cd44707a01fb53f91aad5f7 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 7 May 2026 01:25:00 -0800 Subject: [PATCH 27/30] chore(tier0): extract wallet denial classifier and expand service tests Move the wallet-denial taxonomy out of sendTransaction.ts (which pulls in @solana/web3.js + react-native polyfills at the top of the file) into src/utils/walletDenial.ts so the validate-tier0-services loader can exercise the 12 denial-fragment patterns without dragging in React Native dependencies under Node. Expanded validate-tier0-services.mjs: - 12 wallet-denial positives across Error, plain-object error, and code-only shapes; 8 negatives covering network errors, RPC param errors, null/undefined, and empty objects - 5 additional parseBaseUnits edges (whitespace, scientific notation, comma decimal, just ".", empty string, large whole numbers) - 3 solanaPayUri rejection paths (10-decimal precision overflow, negative, non-numeric) asserting the bad amount is dropped from the URI rather than encoded - 5 additional summarizeError cases covering Error with no message, Error chained to Error via cause, null/undefined inputs, and message/error/name precedence - Realistic-length explorer-tx URL assertion Enable allowImportingTsExtensions so walletDenial.ts can use the explicit ./errors.ts relative import that the Node ESM loader needs under --experimental-transform-types. Other source files still use @/ aliases via the bundler resolver and are unaffected. --- .../scripts/validate-tier0-services.mjs | 104 ++++++++++++++++++ mobile_app/src/services/sendTransaction.ts | 23 +--- mobile_app/src/utils/walletDenial.ts | 23 ++++ mobile_app/tsconfig.json | 1 + 4 files changed, 129 insertions(+), 22 deletions(-) create mode 100644 mobile_app/src/utils/walletDenial.ts diff --git a/mobile_app/scripts/validate-tier0-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs index 0e0c67a6..7d152608 100644 --- a/mobile_app/scripts/validate-tier0-services.mjs +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -14,6 +14,7 @@ const { formatRecoveryKey } = await import("../src/utils/recoveryKey.ts"); const { parseBaseUnits } = await import("../src/utils/amount.ts"); const { summarizeError } = await import("../src/utils/errors.ts"); const { buildDevnetExplorerTxUrl } = await import("../src/services/explorer.ts"); +const { isWalletDenial } = await import("../src/utils/walletDenial.ts"); function key(index) { const seed = new Uint8Array(32); @@ -44,6 +45,24 @@ function testSolanaPayUri() { ); assert.throws(() => buildSolanaPayUri({ recipient: " " }), /Recipient is required/); + + const tooManyDecimals = buildSolanaPayUri({ + recipient: "11111111111111111111111111111111", + amount: "1.0000000001", + }); + assert.ok(!tooManyDecimals.includes("amount="), "10-decimal SOL amount should be dropped, not encoded"); + + const negativeAmount = buildSolanaPayUri({ + recipient: "11111111111111111111111111111111", + amount: "-1", + }); + assert.ok(!negativeAmount.includes("amount="), "negative amount should be dropped"); + + const nanAmount = buildSolanaPayUri({ + recipient: "11111111111111111111111111111111", + amount: "abc", + }); + assert.ok(!nanAmount.includes("amount="), "non-numeric amount should be dropped"); } function testAddressBookCore() { @@ -102,10 +121,17 @@ function testBaseUnitParsing() { assert.equal(parseBaseUnits("1", 9), 1_000_000_000n); assert.equal(parseBaseUnits("0.000000001", 9), 1n); assert.equal(parseBaseUnits("001.2300", 6), 1_230_000n); + assert.equal(parseBaseUnits(" 1.5 ", 9), 1_500_000_000n); + assert.equal(parseBaseUnits("1000000", 6), 1_000_000_000_000n); assert.throws(() => parseBaseUnits("1abc", 9), /Invalid amount/); assert.throws(() => parseBaseUnits("1.0000000001", 9), /Too many decimal places/); assert.throws(() => parseBaseUnits("0", 9), /Invalid amount/); + assert.throws(() => parseBaseUnits("0.0", 9), /Invalid amount/); assert.throws(() => parseBaseUnits("-1", 9), /Invalid amount/); + assert.throws(() => parseBaseUnits("", 9), /Invalid amount/); + assert.throws(() => parseBaseUnits(".", 9), /Invalid amount/); + assert.throws(() => parseBaseUnits("1e3", 9), /Invalid amount/); + assert.throws(() => parseBaseUnits("1,5", 9), /Invalid amount/); } function testExplorerUrls() { @@ -113,6 +139,11 @@ function testExplorerUrls() { buildDevnetExplorerTxUrl("abc+/="), "https://explorer.solana.com/tx/abc%2B%2F%3D?cluster=devnet", ); + const realLookingSig = "5".repeat(88); + assert.equal( + buildDevnetExplorerTxUrl(realLookingSig), + `https://explorer.solana.com/tx/${realLookingSig}?cluster=devnet`, + ); } function testErrorSummaries() { @@ -132,6 +163,78 @@ function testErrorSummaries() { summarizeError(new Error("boom"), "fallback message").message, "boom", ); + assert.equal( + summarizeError(new Error(""), "fallback message").message, + "fallback message", + ); + const wrapped = new Error("outer"); + wrapped.cause = new Error("inner"); + assert.match( + summarizeError(wrapped, "fallback").cause ?? "", + /inner/, + ); + assert.equal( + summarizeError(null, "fallback").message, + "fallback", + ); + assert.equal( + summarizeError(undefined, "fallback").message, + "fallback", + ); + assert.equal( + summarizeError({ message: "primary", error: "secondary", code: 42 }, "fallback").message, + "primary", + ); + assert.equal( + summarizeError({ name: "Boom", error: "Bang" }, "fallback").message, + "Boom", + ); +} + +function testWalletDenialPatterns() { + // Each fragment from DENIAL_FRAGMENTS — case insensitive, embedded in + // error message, error name, error code, or raw object — must classify + // as a user-cancellation so the UI shows "you cancelled" instead of + // "wallet signing failed". + const positives = [ + new Error("Authentication cancelled"), + new Error("Authorization request failed"), + new Error("AUTH REQUEST FAILED on Seed Vault"), + new Error("user cancelled the authorization"), + new Error("transaction was canceled by user"), + new Error("User declined"), + new Error("Permission denied"), + new Error("Operation rejected"), + new Error("user refused to sign"), + { error: "Cancelled", code: -32000 }, + { name: "AuthCancelled", message: "" }, + { code: "USER_REJECTED" }, + ]; + for (const err of positives) { + assert.equal( + isWalletDenial(err), + true, + `expected denial: ${typeof err === "object" ? JSON.stringify(err) : String(err)}`, + ); + } + + const negatives = [ + new Error("Network request timed out"), + new Error("Insufficient funds for fee"), + new Error("Blockhash not found"), + { code: -32602, message: "Invalid params" }, + null, + undefined, + "", + {}, + ]; + for (const err of negatives) { + assert.equal( + isWalletDenial(err), + false, + `expected non-denial: ${typeof err === "object" ? JSON.stringify(err) : String(err)}`, + ); + } } testSolanaPayUri(); @@ -140,4 +243,5 @@ testRecoveryKeyFormatting(); testBaseUnitParsing(); testExplorerUrls(); testErrorSummaries(); +testWalletDenialPatterns(); console.log("Tier 0 service checks passed"); diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 1bc469b0..67ade34e 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -21,6 +21,7 @@ import { buildDevnetExplorerTxUrl } from "@/src/services/explorer"; import { SecureKeys, secureGet, secureSet } from "@/src/storage"; import { parseBaseUnits } from "@/src/utils/amount"; import { summarizeError } from "@/src/utils/errors"; +import { isWalletDenial } from "@/src/utils/walletDenial"; const APP_IDENTITY = { name: "anonmesh", uri: "https://anonme.sh", @@ -86,28 +87,6 @@ interface MwaAuthResult { accounts: { address: string }[]; } -function isWalletDenial(err: unknown): boolean { - const summary = summarizeError(err, ""); - const normalized = [ - summary.message, - summary.name, - summary.code, - summary.raw, - ].filter(Boolean).join(" ").toLowerCase(); - return ( - normalized.includes("authentication cancelled") || - normalized.includes("authorization request failed") || - normalized.includes("authorization cancelled") || - normalized.includes("auth request failed") || - normalized.includes("cancelled") || - normalized.includes("canceled") || - normalized.includes("declined") || - normalized.includes("denied") || - normalized.includes("rejected") || - normalized.includes("user refused") - ); -} - function normalizeWalletError(err: unknown, fallback?: string): never { if (isWalletDenial(err)) { throw new TransactionNotApprovedError(); diff --git a/mobile_app/src/utils/walletDenial.ts b/mobile_app/src/utils/walletDenial.ts new file mode 100644 index 00000000..2042b374 --- /dev/null +++ b/mobile_app/src/utils/walletDenial.ts @@ -0,0 +1,23 @@ +import { summarizeError } from "./errors.ts"; + +const DENIAL_FRAGMENTS = [ + "authentication cancelled", + "authorization request failed", + "authorization cancelled", + "auth request failed", + "cancelled", + "canceled", + "declined", + "denied", + "rejected", + "user refused", +]; + +export function isWalletDenial(err: unknown): boolean { + const summary = summarizeError(err, ""); + const haystack = [summary.message, summary.name, summary.code, summary.raw] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return DENIAL_FRAGMENTS.some((fragment) => haystack.includes(fragment)); +} diff --git a/mobile_app/tsconfig.json b/mobile_app/tsconfig.json index 909e9010..1d7d2657 100644 --- a/mobile_app/tsconfig.json +++ b/mobile_app/tsconfig.json @@ -2,6 +2,7 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "allowImportingTsExtensions": true, "paths": { "@/*": [ "./*" From b462a7ecf63836d6f6999d1cdf57f37cfe05d8eb Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 7 May 2026 01:26:16 -0800 Subject: [PATCH 28/30] fix(wallet): write marker last so partial creates self-recover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously LocalWallet.create() wrote WALLET_MARKER='true' before WALLET_SECRET. A crash between those two writes left the keychain in a "marker present, secret missing" state. On the next launch WalletFactory.hasLocalWallet() called LocalWallet.delete() on that partial state — including the orphan WALLET_PUBKEY — and routed the user to onboarding for a fresh keypair. Any funds sent to the first keypair before the crash were unrecoverable from the device. Reorder the four secureSet calls so WALLET_MARKER is the final write. Any earlier failure now leaves the marker absent → exists() returns false → onboarding runs cleanly instead of the recovery branch that destroys partial state. The orphan AES_KEY/PUBKEY/SECRET sit harmlessly until the next create() overwrites them. WalletFactory.createLocal()'s defensive delete-on-not-fully-intact branch is preserved for legacy state from older builds. A confirm dialog around that branch is tracked separately for fast-follow. --- mobile_app/src/infrastructure/wallet/LocalWallet.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile_app/src/infrastructure/wallet/LocalWallet.ts b/mobile_app/src/infrastructure/wallet/LocalWallet.ts index b07b89e6..696abefb 100644 --- a/mobile_app/src/infrastructure/wallet/LocalWallet.ts +++ b/mobile_app/src/infrastructure/wallet/LocalWallet.ts @@ -154,10 +154,15 @@ export class LocalWallet implements IWalletAdapter { const keypair = Keypair.fromSeed(seed); const payload = aesEncrypt(aesKey, keypair.secretKey); + // Marker is the LAST write. If the app dies between any earlier step and + // this line, next launch sees exists()=false and runs onboarding cleanly. + // Reversing this order silently destroys partial-state seeds because + // WalletFactory.hasLocalWallet() delete()s any "marker present, secret + // missing" state to recover from cross-build keychain mismatches. await secureSet(SecureKeys.WALLET_AES_KEY, Buffer.from(aesKey).toString('base64')); await secureSet(SecureKeys.WALLET_PUBKEY, keypair.publicKey.toBase58()); - await secureSet(SecureKeys.WALLET_MARKER, 'true'); await secureSet(SecureKeys.WALLET_SECRET, JSON.stringify(payload)); + await secureSet(SecureKeys.WALLET_MARKER, 'true'); const w = new LocalWallet(); w._publicKey = keypair.publicKey; From 80a01cad156bf0e3cce3dbeee6cc6dfc46034dbb Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 7 May 2026 01:33:15 -0800 Subject: [PATCH 29/30] feat(wallet): filter token-2022 mints from send picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send path uses @solana/spl-token transfer helpers without an explicit programId argument, which defaults to the legacy SPL-Token program. Token-2022 mints belong to a different program and can carry transfer-fee, confidential-transfers, or interest-bearing extensions that the legacy transferInstruction cannot honour. The previous behaviour quietly listed T22 balances as sendable in the picker; the send would either fail with a generic submission error or undercount the recipient amount on transfer-fee mints. Real T22 send is tracked as P3 in HUNTER_PLAN with a real spec (transferCheckedWithFee + extension probe). This change tags every TokenBalance with its owning program at fetch time, hides T22 entries from the send TokenPicker, surfaces a "SPL-2022 · view only" hint on the home balance card so users still see what they hold, and adds an assertSendableSplProgram guard at the bottom of the send pipeline (buildSplTransferTransaction) so even a deep-link or tampered router param cannot reach Keypair.sign or MWA signTransactions with a T22 mint. The programId now flows through the router params chain (RecipientPicker → AmountKeypad → ReviewScreen → ReviewCard) so the review screen can refuse T22 with an explicit explanation before any estimate or send call. The SOL path is unchanged. Tests cover assertSendableSplProgram across all programId shapes (legacy, T22, undefined, empty, unknown). --- mobile_app/app/send/review.tsx | 4 +- mobile_app/components/home/BalanceCard.tsx | 6 +++ mobile_app/components/send/AmountKeypad.tsx | 10 ++++- .../components/send/RecipientPicker.tsx | 1 + mobile_app/components/send/ReviewCard.tsx | 18 ++++++++- mobile_app/components/send/TokenPicker.tsx | 11 ++++- .../scripts/validate-tier0-services.mjs | 30 ++++++++++++++ mobile_app/src/services/sendTransaction.ts | 15 +++++++ mobile_app/src/services/walletData.ts | 40 ++++++++++++++++--- 9 files changed, 125 insertions(+), 10 deletions(-) diff --git a/mobile_app/app/send/review.tsx b/mobile_app/app/send/review.tsx index 87559125..81feaeb3 100644 --- a/mobile_app/app/send/review.tsx +++ b/mobile_app/app/send/review.tsx @@ -4,12 +4,13 @@ import React from "react"; import { ReviewCard } from "@/components/send/ReviewCard"; export default function ReviewScreen() { - const { to, amount, symbol, mint, decimals } = useLocalSearchParams<{ + const { to, amount, symbol, mint, decimals, programId } = useLocalSearchParams<{ to: string; amount: string; symbol: string; mint?: string; decimals?: string; + programId?: string; }>(); return ( @@ -17,6 +18,7 @@ export default function ReviewScreen() { amount={amount ?? "0"} decimals={decimals} mintAddress={mint} + programId={programId} symbol={symbol ?? "SOL"} to={to ?? ""} /> diff --git a/mobile_app/components/home/BalanceCard.tsx b/mobile_app/components/home/BalanceCard.tsx index 9909a23c..5f45093c 100644 --- a/mobile_app/components/home/BalanceCard.tsx +++ b/mobile_app/components/home/BalanceCard.tsx @@ -136,6 +136,7 @@ export function BalanceCard() { {splTokens.map((token: TokenBalance) => { const color = TOKEN_COLORS[token.symbol] ?? colors.textSecondary; const amount = hidden ? HIDDEN_MASK : formatTokenAmount(token.uiAmount, token.maxDecimals); + const isToken2022 = token.programId === "spl-token-2022"; return ( @@ -150,6 +151,11 @@ export function BalanceCard() { {token.name} + {isToken2022 ? ( + + SPL-2022 · view only + + ) : null} ); })} diff --git a/mobile_app/components/send/AmountKeypad.tsx b/mobile_app/components/send/AmountKeypad.tsx index 7db9abe9..1ee107cf 100644 --- a/mobile_app/components/send/AmountKeypad.tsx +++ b/mobile_app/components/send/AmountKeypad.tsx @@ -29,9 +29,16 @@ export function AmountKeypad() { const { decimals: decimalsParam, mint, + programId: programIdParam, symbol: symbolParam, to, - } = useLocalSearchParams<{ decimals?: string; mint?: string; symbol?: string; to: string }>(); + } = useLocalSearchParams<{ + decimals?: string; + mint?: string; + programId?: string; + symbol?: string; + to: string; + }>(); const { tokens } = useWalletBalance(); const symbol = typeof symbolParam === "string" && symbolParam.length > 0 ? symbolParam : "SOL"; @@ -62,6 +69,7 @@ export function AmountKeypad() { amount, decimals: String(Number.isFinite(tokenDecimals) ? tokenDecimals : token.maxDecimals), mint: typeof mint === "string" ? mint : "", + programId: typeof programIdParam === "string" ? programIdParam : (token.programId ?? ""), symbol: token.symbol, to: recipient, }, diff --git a/mobile_app/components/send/RecipientPicker.tsx b/mobile_app/components/send/RecipientPicker.tsx index ac151851..19085a4f 100644 --- a/mobile_app/components/send/RecipientPicker.tsx +++ b/mobile_app/components/send/RecipientPicker.tsx @@ -107,6 +107,7 @@ export function RecipientPicker() { params: { decimals: String(token.maxDecimals), mint: token.mintAddress ?? "", + programId: token.programId ?? "", symbol: token.symbol, to: trimmedAddress, }, diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index a26be145..f5e3f01a 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -72,6 +72,7 @@ interface ReviewCardProps { readonly symbol: string; readonly mintAddress?: string | string[]; readonly decimals?: string | string[]; + readonly programId?: string | string[]; } // ── DetailRow ───────────────────────────────────────────────────────────────── @@ -110,7 +111,7 @@ function DetailRow({ icon, label, secondary, value, valueComponent, colors }: De // ── ReviewCard ──────────────────────────────────────────────────────────────── -export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: ReviewCardProps) { +export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programId }: ReviewCardProps) { const router = useRouter(); const { colors } = useTheme(); const { wallet } = useWallet(); @@ -121,8 +122,10 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review const [isConfirming, setIsConfirming] = useState(false); const [sliderResetKey, setSliderResetKey] = useState(0); const normalizedMint = typeof mintAddress === "string" ? mintAddress : ""; + const normalizedProgramId = typeof programId === "string" ? programId : ""; const tokenDecimals = typeof decimals === "string" && decimals.length > 0 ? Number.parseInt(decimals, 10) : 6; + const isToken2022 = normalizedProgramId === "spl-token-2022"; useEffect(() => { let cancelled = false; @@ -152,6 +155,7 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review amount, mintAddress: normalizedMint, decimals: tokenDecimals, + programId: normalizedProgramId, }), FEE_ESTIMATE_TIMEOUT_MS, ); @@ -165,7 +169,7 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review return () => { cancelled = true; }; - }, [amount, normalizedMint, symbol, to, tokenDecimals, wallet]); + }, [amount, normalizedMint, normalizedProgramId, symbol, to, tokenDecimals, wallet]); async function handleConfirm() { if (isConfirming) return; @@ -179,6 +183,15 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review return; } + if (symbol !== "SOL" && isToken2022) { + setError({ + kind: "unsupported", + message: `${symbol} is a Token-2022 mint. Token-2022 sends are not supported yet — coming soon.`, + }); + setSliderResetKey((k) => k + 1); + return; + } + if (!wallet) { setError({ kind: "send", message: "Wallet not connected" }); setSliderResetKey((k) => k + 1); @@ -213,6 +226,7 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals }: Review amount, mintAddress: normalizedMint, decimals: tokenDecimals, + programId: normalizedProgramId, }); await saveAddressBookRecipient(to); diff --git a/mobile_app/components/send/TokenPicker.tsx b/mobile_app/components/send/TokenPicker.tsx index 59b70d45..5f0cb994 100644 --- a/mobile_app/components/send/TokenPicker.tsx +++ b/mobile_app/components/send/TokenPicker.tsx @@ -56,6 +56,14 @@ export function tokenByName(sym: string, tokens: TokenBalance[] = []): TokenOpti }; } +// Token-2022 mints are filtered out of the picker. The send path defaults +// to the legacy SPL-Token program and silently misbehaves on T22 mints +// that have transfer-fee or other extensions, so we keep T22 read-only +// in the balance list and never offer it as a send option. +function isSendable(token: TokenBalance): boolean { + return token.programId !== "spl-token-2022"; +} + interface TokenPickerProps { visible: boolean; selected: string; @@ -100,7 +108,8 @@ export function TokenPicker({ visible, selected, onSelect, onClose }: TokenPicke transform: [{ translateY: translateY.value }], })); - const visibleTokens = tokens.length > 0 ? tokens : [DEFAULT_SOL_TOKEN]; + const sendable = tokens.filter(isSendable); + const visibleTokens = sendable.length > 0 ? sendable : [DEFAULT_SOL_TOKEN]; return ( diff --git a/mobile_app/scripts/validate-tier0-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs index 7d152608..1a98188f 100644 --- a/mobile_app/scripts/validate-tier0-services.mjs +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -15,6 +15,7 @@ const { parseBaseUnits } = await import("../src/utils/amount.ts"); const { summarizeError } = await import("../src/utils/errors.ts"); const { buildDevnetExplorerTxUrl } = await import("../src/services/explorer.ts"); const { isWalletDenial } = await import("../src/utils/walletDenial.ts"); +const { assertSendableSplProgram, UnsupportedTokenProgramError } = await import("../src/services/walletData.ts"); function key(index) { const seed = new Uint8Array(32); @@ -237,6 +238,34 @@ function testWalletDenialPatterns() { } } +function testSplProgramGuard() { + // The bottom-line guard against Token-2022 sends. Picker hides T22 + // upstream; this is the last line of defense before signing keys see + // the transaction. Test all known shapes of programId input. + assertSendableSplProgram("spl-token"); + + assert.throws( + () => assertSendableSplProgram("spl-token-2022"), + (err) => err instanceof UnsupportedTokenProgramError && /Token-2022/.test(err.message), + "spl-token-2022 must throw UnsupportedTokenProgramError", + ); + assert.throws( + () => assertSendableSplProgram(undefined), + (err) => err instanceof UnsupportedTokenProgramError, + "missing programId must throw (defense-in-depth against tampered router params)", + ); + assert.throws( + () => assertSendableSplProgram(""), + (err) => err instanceof UnsupportedTokenProgramError, + "empty programId must throw", + ); + assert.throws( + () => assertSendableSplProgram("spl-token-3000"), + (err) => err instanceof UnsupportedTokenProgramError, + "unknown programId must throw", + ); +} + testSolanaPayUri(); testAddressBookCore(); testRecoveryKeyFormatting(); @@ -244,4 +273,5 @@ testBaseUnitParsing(); testExplorerUrls(); testErrorSummaries(); testWalletDenialPatterns(); +testSplProgramGuard(); console.log("Tier 0 service checks passed"); diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 67ade34e..29016a8b 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -18,6 +18,7 @@ import { Buffer } from "buffer"; import type { IRpcAdapter } from "@/src/infrastructure/network"; import type { IWalletAdapter } from "@/src/infrastructure/wallet"; import { buildDevnetExplorerTxUrl } from "@/src/services/explorer"; +import { assertSendableSplProgram } from "@/src/services/walletData"; import { SecureKeys, secureGet, secureSet } from "@/src/storage"; import { parseBaseUnits } from "@/src/utils/amount"; import { summarizeError } from "@/src/utils/errors"; @@ -54,6 +55,7 @@ export interface SendSplParams { amount: string; mintAddress: string; decimals: number; + programId?: string; } export interface EstimateSolTransferFeeParams { @@ -68,6 +70,7 @@ export interface EstimateSplTransferFeeParams { amount: string; mintAddress: string; decimals: number; + programId?: string; } export interface SendResult { @@ -137,13 +140,21 @@ async function buildSplTransferTransaction({ amount, mintAddress, decimals, + programId, }: { fromPubkey: PublicKey; recipientAddress: string; amount: string; mintAddress: string; decimals: number; + programId?: string; }): Promise { + // Bottom-line guard against any caller (including direct deep-links to + // /send/review with a tampered programId param) trying to build an SPL + // transfer for a Token-2022 mint. The picker filters T22 upstream; this + // is the last line of defense before signing keys see the transaction. + assertSendableSplProgram(programId); + let toOwner: PublicKey; let mint: PublicKey; try { @@ -275,6 +286,7 @@ export async function estimateSplTransferFeeLamports({ amount, mintAddress, decimals, + programId, }: EstimateSplTransferFeeParams): Promise { const fromPubkey = walletAdapter.getPublicKey(); if (!fromPubkey) { @@ -287,6 +299,7 @@ export async function estimateSplTransferFeeLamports({ amount, mintAddress, decimals, + programId, }); const { blockhash } = await solanaConnection.getLatestBlockhash("confirmed"); tx.recentBlockhash = blockhash; @@ -334,6 +347,7 @@ export async function sendSplTransfer({ amount, mintAddress, decimals, + programId, }: SendSplParams): Promise { const fromPubkey = walletAdapter.getPublicKey(); if (!fromPubkey) { @@ -346,6 +360,7 @@ export async function sendSplTransfer({ amount, mintAddress, decimals, + programId, }); return signAndSubmitTransaction({ walletAdapter, rpcAdapter, tx, expectedPubkey: fromPubkey }); } diff --git a/mobile_app/src/services/walletData.ts b/mobile_app/src/services/walletData.ts index 1d38360b..6ffc8971 100644 --- a/mobile_app/src/services/walletData.ts +++ b/mobile_app/src/services/walletData.ts @@ -12,12 +12,36 @@ export const SOL_DECIMALS = 9; const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); const TOKEN_2022_PROGRAM_ID = new PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"); +export type SplProgramId = "spl-token" | "spl-token-2022"; + export interface TokenBalance { symbol: string; name: string; uiAmount: number; maxDecimals: number; mintAddress?: string; + // Tags the SPL program that owns the mint. Token-2022 mints are filtered + // from the send picker; sendSplTransfer refuses to build a transaction + // unless this is "spl-token" because @solana/spl-token transfer helpers + // default to the legacy program ID and silently misbehave on T22 mints + // that have transfer-fee, confidential-transfers, or interest-bearing + // extensions. Native SOL omits this field. + programId?: SplProgramId; +} + +export class UnsupportedTokenProgramError extends Error { + constructor(programId: string) { + super(`Token-2022 sends are not supported yet (program ${programId})`); + this.name = "UnsupportedTokenProgramError"; + } +} + +export function assertSendableSplProgram(programId: string | undefined): asserts programId is "spl-token" { + if (programId === "spl-token") return; + if (!programId || programId === "spl-token-2022") { + throw new UnsupportedTokenProgramError(programId ?? "unknown"); + } + throw new UnsupportedTokenProgramError(programId); } export interface WalletSnapshot { @@ -53,16 +77,21 @@ export async function fetchSplTokens( connection: Connection, publicKey: PublicKey, ): Promise { - const programs = [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]; + const programs: { id: PublicKey; tag: SplProgramId }[] = [ + { id: TOKEN_PROGRAM_ID, tag: "spl-token" }, + { id: TOKEN_2022_PROGRAM_ID, tag: "spl-token-2022" }, + ]; const results = await Promise.all( - programs.map((programId) => - connection.getParsedTokenAccountsByOwner(publicKey, { programId }), + programs.map(({ id, tag }) => + connection + .getParsedTokenAccountsByOwner(publicKey, { programId: id }) + .then((response) => ({ response, tag })), ), ); const tokens: TokenBalance[] = []; - for (const { value } of results) { - for (const { account } of value) { + for (const { response, tag } of results) { + for (const { account } of response.value) { const info = account.data.parsed?.info; const tokenAmount = info?.tokenAmount; if (!tokenAmount) continue; @@ -80,6 +109,7 @@ export async function fetchSplTokens( uiAmount, maxDecimals: decimals, mintAddress: mint, + programId: tag, }); } } From fbdee4c00e3ff35d6f54ea5e2afa9bb5ff1579b7 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 7 May 2026 02:23:50 -0800 Subject: [PATCH 30/30] fix(wallet): hide non-SOL token entries from send picker pending root-cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy devnet SPL transfers (USDC) are currently failing on-device with a separate pre-existing error that needs root-cause work. Until that lands, the picker should not offer a send path that does not work; the known-issues banner in the PR description is not enough on its own. Extend the existing send-picker filter — already hiding Token-2022 mints because legacy transferInstruction misbehaves on T22 extensions — to also hide every legacy SPL entry. The balance card still surfaces SPL holdings so users see what they hold; the send picker simply does not list them. The picker footer switches from "Balances pulled live from devnet" to "Token sends temporarily SOL-only — coming soon" whenever SPL holdings are present but hidden. SOL send is the only adapter-routed transfer that has been verified end-to-end, so this commit ships a wallet that does what its UI says instead of one that surfaces a broken submission path. --- mobile_app/components/send/TokenPicker.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/mobile_app/components/send/TokenPicker.tsx b/mobile_app/components/send/TokenPicker.tsx index 5f0cb994..198d3770 100644 --- a/mobile_app/components/send/TokenPicker.tsx +++ b/mobile_app/components/send/TokenPicker.tsx @@ -56,12 +56,15 @@ export function tokenByName(sym: string, tokens: TokenBalance[] = []): TokenOpti }; } -// Token-2022 mints are filtered out of the picker. The send path defaults -// to the legacy SPL-Token program and silently misbehaves on T22 mints -// that have transfer-fee or other extensions, so we keep T22 read-only -// in the balance list and never offer it as a send option. +// Send picker is temporarily SOL-only. Token-2022 has been filtered since +// the legacy transferInstruction silently misbehaves on T22 extensions, and +// legacy SPL devnet send is currently failing on-device with a separate +// pre-existing error (under root-cause). Until that lands, hide every SPL +// entry from the picker — balance card still surfaces SPL holdings as +// view-only so users see what they hold without a broken send path. SOL +// is the only adapter-routed send that has been verified end-to-end. function isSendable(token: TokenBalance): boolean { - return token.programId !== "spl-token-2022"; + return token.symbol === "SOL" && !token.programId; } interface TokenPickerProps { @@ -110,6 +113,7 @@ export function TokenPicker({ visible, selected, onSelect, onClose }: TokenPicke const sendable = tokens.filter(isSendable); const visibleTokens = sendable.length > 0 ? sendable : [DEFAULT_SOL_TOKEN]; + const hiddenSplCount = tokens.length - sendable.length; return ( @@ -255,7 +259,9 @@ export function TokenPicker({ visible, selected, onSelect, onClose }: TokenPicke fontSize: fontSize.xs, }} > - Balances pulled live from devnet + {hiddenSplCount > 0 + ? "Token sends temporarily SOL-only — coming soon" + : "Balances pulled live from devnet"}