diff --git a/mobile_app/.env.example b/mobile_app/.env.example index e0a54c6e..4069648b 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 e30f7b30..77020462 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" @@ -24,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" @@ -79,6 +95,7 @@ ], "expo-secure-store", "expo-local-authentication", + "./plugins/withDisableAndroidContentCapture", [ "expo-notifications", { @@ -87,7 +104,8 @@ } ], "@magicred-1/react-native-lxmf", - "./plugins/withAndroidForegroundService" + "./plugins/withAndroidForegroundService", + "expo-web-browser" ], "experiments": { "typedRoutes": true, diff --git a/mobile_app/app/_layout.tsx b/mobile_app/app/_layout.tsx index 78d68af5..3e58a382 100644 --- a/mobile_app/app/_layout.tsx +++ b/mobile_app/app/_layout.tsx @@ -58,6 +58,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(); @@ -77,9 +84,6 @@ function AppShell() { }, []); useBackgroundService(); - const [notifsEnabled] = useNotificationEnabled(); - useMessageNotifications(handleInApp, notifsEnabled); - usePeerCountNotification(notifsEnabled); return ( @@ -87,7 +91,9 @@ function AppShell() { + + @@ -98,6 +104,7 @@ function AppShell() { + 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/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/receive.tsx b/mobile_app/app/receive.tsx index 9e742ca4..f28c7cef 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,15 +236,13 @@ export default function ReceiveScreen() { - SOLANA{isStealth ? " · STEALTH" : ""} + {isStealth ? "SOLANA · STEALTH" : "SOLANA PAY"} {isStealth && ( - only works with{" "} - anonmesh - {" "}senders + preview only · not a spendable Solana address )} @@ -297,6 +327,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/app/send/review.tsx b/mobile_app/app/send/review.tsx index a556563f..81feaeb3 100644 --- a/mobile_app/app/send/review.tsx +++ b/mobile_app/app/send/review.tsx @@ -4,11 +4,23 @@ 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, programId } = useLocalSearchParams<{ to: string; amount: string; symbol: string; + mint?: string; + decimals?: string; + programId?: string; }>(); - return ; + return ( + + ); } 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/assets/images/favicon.png b/mobile_app/assets/images/favicon.png index ec152e35..bd5aabbb 100644 Binary files a/mobile_app/assets/images/favicon.png and b/mobile_app/assets/images/favicon.png differ diff --git a/mobile_app/assets/images/icon.png b/mobile_app/assets/images/icon.png index ec152e35..bd5aabbb 100644 Binary files a/mobile_app/assets/images/icon.png and b/mobile_app/assets/images/icon.png differ diff --git a/mobile_app/components/home/BalanceCard.tsx b/mobile_app/components/home/BalanceCard.tsx index 6f07da1f..5f45093c 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(); @@ -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/home/NearbyPeersCard.tsx b/mobile_app/components/home/NearbyPeersCard.tsx index eefec2cd..51f47ff7 100644 --- a/mobile_app/components/home/NearbyPeersCard.tsx +++ b/mobile_app/components/home/NearbyPeersCard.tsx @@ -17,10 +17,9 @@ function initialOf(alias: string | undefined): string { // Peer presence strip above Recent. // -// LxmfContext.peers is a persistent accumulator (keyed by destHash) that -// monotonically grows as announces arrive — so on mount it's briefly -// empty before events flush through. To avoid "0 → 6" flicker we use -// `useMemo` + filter for stability. +// LxmfContext.peers is keyed by destHash and pruned to recent announces. +// On mount it can still be briefly empty before events flush through, so +// `useMemo` + filtering keeps the visible state stable. // // Tap opens teammate's MeshMap on the Nodes tab — he owns peer // visualization; we don't duplicate. @@ -59,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 8c9447a1..41457d76 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); @@ -82,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."} ); } 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 ( - 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/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/send/AmountKeypad.tsx b/mobile_app/components/send/AmountKeypad.tsx index 0d2a152a..1ee107cf 100644 --- a/mobile_app/components/send/AmountKeypad.tsx +++ b/mobile_app/components/send/AmountKeypad.tsx @@ -26,11 +26,27 @@ function shortAddress(addr: string) { export function AmountKeypad() { const router = useRouter(); const { colors } = useTheme(); - const { to, symbol: symbolParam } = useLocalSearchParams<{ to: string; symbol?: string }>(); + const { + decimals: decimalsParam, + mint, + programId: programIdParam, + symbol: symbolParam, + to, + } = useLocalSearchParams<{ + decimals?: string; + mint?: string; + programId?: 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 +67,9 @@ export function AmountKeypad() { pathname: "/send/review", params: { 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 178ed305..19085a4f 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(); @@ -102,7 +104,13 @@ export function RecipientPicker() { haptics.confirm(); router.push({ pathname: "/send/amount", - params: { to: trimmedAddress, symbol: token.symbol }, + params: { + decimals: String(token.maxDecimals), + mint: token.mintAddress ?? "", + programId: token.programId ?? "", + symbol: token.symbol, + to: trimmedAddress, + }, }); } @@ -126,6 +134,11 @@ export function RecipientPicker() { setAddress(DEMO_RECIPIENT_ADDRESS); } + function handleSelectRecent(pubkey: string) { + haptics.select(); + setAddress(pubkey); + } + return ( @@ -181,6 +194,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 @@ -348,6 +404,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 24f9cfb5..f5e3f01a 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -9,12 +9,16 @@ 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, + sendSplTransfer, sendSolTransfer, 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 { @@ -66,6 +70,9 @@ interface ReviewCardProps { readonly to: string; readonly amount: string; readonly symbol: string; + readonly mintAddress?: string | string[]; + readonly decimals?: string | string[]; + readonly programId?: string | string[]; } // ── DetailRow ───────────────────────────────────────────────────────────────── @@ -104,37 +111,54 @@ 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, programId }: ReviewCardProps) { const router = useRouter(); const { colors } = useTheme(); 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); 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; 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: amount, + }), + FEE_ESTIMATE_TIMEOUT_MS, + ) + : await withTimeout( + estimateSplTransferFeeLamports({ + walletAdapter: wallet, + recipientAddress: to, + amount, + mintAddress: normalizedMint, + decimals: tokenDecimals, + programId: normalizedProgramId, + }), + FEE_ESTIMATE_TIMEOUT_MS, + ); if (!cancelled) setFeeLabel(formatSolFee(lamports)); } catch { if (!cancelled) setFeeLabel("Fee unavailable"); @@ -145,13 +169,25 @@ export function ReviewCard({ to, amount, symbol }: ReviewCardProps) { return () => { cancelled = true; }; - }, [amount, symbol, to, wallet]); + }, [amount, normalizedMint, normalizedProgramId, 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; + } + + 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; } @@ -175,25 +211,49 @@ 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: amount, + }) + : await sendSplTransfer({ + walletAdapter: wallet, + rpcAdapter, + recipientAddress: to, + amount, + mintAddress: normalizedMint, + decimals: tokenDecimals, + programId: normalizedProgramId, + }); + + await saveAddressBookRecipient(to); router.push({ pathname: "/send/success", params: { amount, symbol, txId: result.signature }, }); } catch (err: unknown) { + const summary = summarizeError(err, "Transaction failed before the wallet returned a reason"); + console.error("[send/ReviewCard] transfer failed", { + 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, + }); 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: summary.message }, ); setSliderResetKey((k) => k + 1); } finally { @@ -288,24 +348,29 @@ export function ReviewCard({ to, amount, symbol }: ReviewCardProps) { ) : 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/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/send/TokenPicker.tsx b/mobile_app/components/send/TokenPicker.tsx index 59b70d45..198d3770 100644 --- a/mobile_app/components/send/TokenPicker.tsx +++ b/mobile_app/components/send/TokenPicker.tsx @@ -56,6 +56,17 @@ export function tokenByName(sym: string, tokens: TokenBalance[] = []): TokenOpti }; } +// 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.symbol === "SOL" && !token.programId; +} + interface TokenPickerProps { visible: boolean; selected: string; @@ -100,7 +111,9 @@ 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]; + const hiddenSplCount = tokens.length - sendable.length; return ( @@ -246,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"} diff --git a/mobile_app/components/settings/ExportWalletModal.tsx b/mobile_app/components/settings/ExportWalletModal.tsx index b86c382e..151846d4 100644 --- a/mobile_app/components/settings/ExportWalletModal.tsx +++ b/mobile_app/components/settings/ExportWalletModal.tsx @@ -1,7 +1,8 @@ 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'; 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); }; @@ -47,82 +58,98 @@ export function ExportWalletModal({ onClose }: { onClose: () => void }) { const masked = secretKey ? '·'.repeat(secretKey.length) : ''; return ( - - - - - - - - - - - - EXPORT WALLET - - {walletMode === 'mwa' ? 'not available' : 'secret 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 - + ) : ( + + + + + This wallet does not use a 12- or 24-word seed phrase. The copied base58 recovery key is the backup; store it offline only. + - ) : ( - - - - - Never share this key. Store offline only. Anyone with this key controls your wallet. - - - - setRevealed(true)} - onRevealOut={() => setRevealed(false)} - onCopy={copyKey} - /> - - {!!secretKey && ( + + setRevealed(true)} + onRevealOut={() => setRevealed(false)} + onCopy={copyKey} + /> + + {!!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 + + + )} + + ); } 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 }, @@ -136,6 +163,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/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/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', }}> - - CONFIDENTIAL · MPC-SHIELDED RECEIVE + + SHARE THIS QR - YOUR FIRST INBOUND SHOWS IN ACTIVITY diff --git a/mobile_app/components/wallet/TxDetailModal.tsx b/mobile_app/components/wallet/TxDetailModal.tsx new file mode 100644 index 00000000..d356735e --- /dev/null +++ b/mobile_app/components/wallet/TxDetailModal.tsx @@ -0,0 +1,228 @@ +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 { buildDevnetExplorerTxUrl } from "@/src/services/explorer"; +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 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(buildDevnetExplorerTxUrl(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/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx index a06f278e..4d9f89c4 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, @@ -20,6 +21,9 @@ import { requestBLEPermissions } from '@/src/utils/blePermissions'; import * as ExpoCrypto from 'expo-crypto'; 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; @@ -137,6 +141,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 []; @@ -231,7 +236,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; @@ -248,6 +255,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 }; @@ -285,6 +329,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; @@ -381,31 +447,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) @@ -461,12 +541,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; @@ -491,12 +573,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; @@ -613,7 +695,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/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; } diff --git a/mobile_app/package-lock.json b/mobile_app/package-lock.json index 29a9bf4d..f0a54254 100644 --- a/mobile_app/package-lock.json +++ b/mobile_app/package-lock.json @@ -17,44 +17,46 @@ "@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", "@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", "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", "expo-splash-screen": "~31.0.13", "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", @@ -1790,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", @@ -2127,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", @@ -2139,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" @@ -2148,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" @@ -2212,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", @@ -2244,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", @@ -2267,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" }, @@ -2280,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": { @@ -2328,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" @@ -2340,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", @@ -2464,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", @@ -3370,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" } }, @@ -3787,6 +3596,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 +3647,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 +3770,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", @@ -3878,7 +3871,100 @@ } } }, - "node_modules/@solana/errors/node_modules/chalk": { + "node_modules/@solana/errors/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/errors/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "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==", @@ -3890,13 +3976,62 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@solana/errors/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "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": ">=20" + "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": { @@ -5717,6 +5852,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", @@ -7374,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" @@ -7436,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": "*", @@ -7497,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" @@ -7509,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": { @@ -7525,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", @@ -7561,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" @@ -7582,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": "*", @@ -7709,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": { @@ -7724,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" @@ -7736,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": { @@ -7749,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", @@ -7765,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": { @@ -7795,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", @@ -8067,6 +8233,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", @@ -8077,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" @@ -8153,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", @@ -8208,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", @@ -8223,6 +8591,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 +8656,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", @@ -9987,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" @@ -12147,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": { @@ -13681,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 6064eddf..6025fcf0 100644 --- a/mobile_app/package.json +++ b/mobile_app/package.json @@ -9,7 +9,9 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", - "lint": "expo lint" + "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": { "@expo-google-fonts/space-grotesk": "^0.4.1", @@ -20,44 +22,46 @@ "@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", "@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", "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", "expo-splash-screen": "~31.0.13", "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", 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/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; 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/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]); diff --git a/mobile_app/screens/SettingsScreen.tsx b/mobile_app/screens/SettingsScreen.tsx index 502a695c..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,8 +231,9 @@ export default function SettingsScreen() { { if (v) setBiometric(true); else setDisableBioOpen(true); }} />} /> + } onPress={() => router.push(CONTACTS_ROUTE)} /> } onPress={() => setRotateOpen(true)} /> - } onPress={() => setExportOpen(true)} last /> + } onPress={() => setExportOpen(true)} last /> 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" 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-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs new file mode 100644 index 00000000..1a98188f --- /dev/null +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -0,0 +1,277 @@ +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"); +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"); +const { assertSendableSplProgram, UnsupportedTokenProgramError } = await import("../src/services/walletData.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/); + + 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() { + 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}`); +} + +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"); +} + +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() { + assert.equal( + 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() { + 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", + ); + 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)}`, + ); + } +} + +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(); +testBaseUnitParsing(); +testExplorerUrls(); +testErrorSummaries(); +testWalletDenialPatterns(); +testSplProgramGuard(); +console.log("Tier 0 service checks passed"); diff --git a/mobile_app/scripts/validate-tier0.sh b/mobile_app/scripts/validate-tier0.sh new file mode 100755 index 00000000..4df7d74a --- /dev/null +++ b/mobile_app/scripts/validate-tier0.sh @@ -0,0 +1,54 @@ +#!/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 "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 + 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 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..696abefb 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'; @@ -152,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; 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/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; diff --git a/mobile_app/src/services/addressBook.ts b/mobile_app/src/services/addressBook.ts new file mode 100644 index 00000000..6579bbb3 --- /dev/null +++ b/mobile_app/src/services/addressBook.ts @@ -0,0 +1,82 @@ +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 type { AddressBookEntry } from "@/src/services/addressBookCore"; + +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 []; + return normalizeAddressBookEntries(parsed); + } catch { + return []; + } +} + +export async function writeAddressBook(entries: AddressBookEntry[]): Promise { + await secureSet(SecureKeys.ADDRESS_BOOK, JSON.stringify(sortAndCapAddressBookEntries(entries))); +} + +export async function saveAddressBookRecipient(pubkey: string, label?: string): Promise { + const entries = await readAddressBook(); + const next = upsertAddressBookEntry(entries, pubkey, label); + await writeAddressBook(next); + return next; +} + +export async function updateAddressBookRecipient( + pubkey: string, + label: string, +): Promise { + const entries = await readAddressBook(); + const next = updateAddressBookEntryLabel(entries, pubkey, label); + await writeAddressBook(next); + return next; +} + +export async function deleteAddressBookRecipient(pubkey: string): Promise { + const next = removeAddressBookEntry(await readAddressBook(), pubkey); + await writeAddressBook(next); + return 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); + }, []); + + 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, updateRecipient, deleteRecipient }; +} 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)); +} 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 6c0f882d..29016a8b 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -1,17 +1,28 @@ +import "@/polyfills"; + import { Connection, Keypair, - LAMPORTS_PER_SOL, PublicKey, 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"; 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"; +import { isWalletDenial } from "@/src/utils/walletDenial"; const APP_IDENTITY = { name: "anonmesh", uri: "https://anonme.sh", @@ -34,13 +45,32 @@ export interface SendSolParams { walletAdapter: IWalletAdapter; rpcAdapter: IRpcAdapter; recipientAddress: string; - amountSOL: number; + amountSOL: string; +} + +export interface SendSplParams { + walletAdapter: IWalletAdapter; + rpcAdapter: IRpcAdapter; + recipientAddress: string; + amount: string; + mintAddress: string; + decimals: number; + programId?: string; } export interface EstimateSolTransferFeeParams { walletAdapter: IWalletAdapter; recipientAddress: string; - amountSOL: number; + amountSOL: string; +} + +export interface EstimateSplTransferFeeParams { + walletAdapter: IWalletAdapter; + recipientAddress: string; + amount: string; + mintAddress: string; + decimals: number; + programId?: string; } export interface SendResult { @@ -60,34 +90,28 @@ 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(); - 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): 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, @@ -95,7 +119,7 @@ function buildSolTransferTransaction({ }: { fromPubkey: PublicKey; recipientAddress: string; - amountSOL: number; + amountSOL: string; }): Transaction { let toPubkey: PublicKey; try { @@ -104,16 +128,135 @@ 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 }), ); } +async function buildSplTransferTransaction({ + fromPubkey, + recipientAddress, + 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 { + toOwner = new PublicKey(recipientAddress); + mint = new PublicKey(mintAddress); + } catch { + throw new Error("Invalid recipient or token mint"); + } + + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error("Invalid token decimals"); + } + + const rawAmount = parseBaseUnits(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 = expectedPubkey; + + const mode = walletAdapter.getMode(); + + if (mode === "local") { + let secretKey: Uint8Array; + try { + secretKey = await walletAdapter.exportSecretKey(); + } catch (err: unknown) { + normalizeWalletError(err); + } + let signature: string; + try { + const keypair = Keypair.fromSecretKey(secretKey); + tx.sign(keypair); + signature = await submitSignedTransaction(rpcAdapter, tx); + } finally { + secretKey.fill(0); + } + return { signature, explorerUrl: buildDevnetExplorerTxUrl(signature) }; + } + + const cachedToken = await secureGet(SecureKeys.MWA_TOKEN); + const signedTransactions: Transaction[] = []; + 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]; + }); + } catch (err: unknown) { + normalizeWalletError(err, "Wallet signing failed before returning a reason"); + } + + const signedTx = signedTransactions[0]; + if (!signedTx) { + throw new TransactionNotApprovedError(); + } + + const signature = await submitSignedTransaction(rpcAdapter, signedTx); + return { signature, explorerUrl: buildDevnetExplorerTxUrl(signature) }; +} + export async function estimateSolTransferFeeLamports({ walletAdapter, recipientAddress, @@ -137,6 +280,39 @@ export async function estimateSolTransferFeeLamports({ return fee.value; } +export async function estimateSplTransferFeeLamports({ + walletAdapter, + recipientAddress, + amount, + mintAddress, + decimals, + programId, +}: EstimateSplTransferFeeParams): Promise { + const fromPubkey = walletAdapter.getPublicKey(); + if (!fromPubkey) { + throw new Error("Wallet not connected"); + } + + const tx = await buildSplTransferTransaction({ + fromPubkey, + recipientAddress, + amount, + mintAddress, + decimals, + programId, + }); + 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. * @@ -147,8 +323,7 @@ export async function estimateSolTransferFeeLamports({ * 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, @@ -162,62 +337,30 @@ export async function sendSolTransfer({ } const tx = buildSolTransferTransaction({ fromPubkey, recipientAddress, amountSOL }); - const { blockhash } = await rpcAdapter.getLatestBlockhash(); - tx.recentBlockhash = blockhash; - tx.feePayer = fromPubkey; - - const mode = walletAdapter.getMode(); + return signAndSubmitTransaction({ walletAdapter, rpcAdapter, tx, expectedPubkey: fromPubkey }); +} - 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(); - } catch (err: unknown) { - normalizeWalletError(err); - } - let signature: string; - try { - const keypair = Keypair.fromSecretKey(secretKey); - tx.sign(keypair); - signature = await rpcAdapter.sendRawTransaction(tx.serialize()); - } finally { - secretKey.fill(0); - } - return { signature, explorerUrl: explorerUrl(signature) }; +export async function sendSplTransfer({ + walletAdapter, + rpcAdapter, + recipientAddress, + amount, + mintAddress, + decimals, + programId, +}: SendSplParams): Promise { + const fromPubkey = walletAdapter.getPublicKey(); + if (!fromPubkey) { + throw new Error("Wallet not connected"); } - // 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, - identity: APP_IDENTITY, - }); - const sessionPubkey = new PublicKey(Buffer.from(auth.accounts[0].address, "base64")); - - if (sessionPubkey.toBase58() !== fromPubkey.toBase58()) { - throw new Error( - `MWA account mismatch — expected ${fromPubkey.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 tx = await buildSplTransferTransaction({ + fromPubkey, + recipientAddress, + amount, + mintAddress, + decimals, + programId, }); - - const signedTx = signedTransactions[0]; - if (!signedTx) { - throw new TransactionNotApprovedError(); - } - - const signature = await rpcAdapter.sendRawTransaction(signedTx.serialize()); - return { signature, explorerUrl: explorerUrl(signature) }; + return signAndSubmitTransaction({ walletAdapter, rpcAdapter, tx, expectedPubkey: fromPubkey }); } 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}` : ""}`; +} 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/services/walletData.ts b/mobile_app/src/services/walletData.ts index 728d7474..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, }); } } @@ -101,11 +131,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 +149,12 @@ interface TransferInfo { lamports: number; } +interface SplTransferInfo { + source: string; + destination: string; + mint: string | null; +} + function extractSystemTransfer( instruction: ParsedInstruction | PartiallyDecodedInstruction, walletAddress: string, @@ -141,6 +182,96 @@ 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) 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; + 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; + 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); + 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, + }; + } + + 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, @@ -150,11 +281,48 @@ 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 splTransfer = parsedTx.transaction.message.instructions + .map(extractSplTransfer) + .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 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(); + + 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 +333,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, }; } diff --git a/mobile_app/src/storage/index.ts b/mobile_app/src/storage/index.ts index 3c1ff8ea..446b71c9 100644 --- a/mobile_app/src/storage/index.ts +++ b/mobile_app/src/storage/index.ts @@ -26,6 +26,10 @@ 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', + // First-run education gate — kept local to this install + TUTORIAL_COMPLETED: 'tutorial_completed', // MWA auth token MWA_TOKEN: 'mwa_auth_token_v1', } as const; 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; +} diff --git a/mobile_app/src/utils/errors.ts b/mobile_app/src/utils/errors.ts new file mode 100644 index 00000000..c6bef89b --- /dev/null +++ b/mobile_app/src/utils/errors.ts @@ -0,0 +1,76 @@ +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; + } + + 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; + } 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) }; +} 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'); +} 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": { "@/*": [ "./*"