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(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": {
"@/*": [
"./*"