diff --git a/mobile_app/app/_layout.tsx b/mobile_app/app/_layout.tsx
index e38dc3af..0927e5ba 100644
--- a/mobile_app/app/_layout.tsx
+++ b/mobile_app/app/_layout.tsx
@@ -21,6 +21,7 @@ import { Feather } from '@expo/vector-icons';
import { ThemeProvider, useTheme } from '@/theme';
import { WalletProvider } from '@/context/WalletContext';
import { LxmfProvider, useLxmfContext } from '@/context/LxmfContext';
+import { NetworkModeProvider } from '@/context/NetworkModeContext';
import { HideBalanceProvider } from '@/src/hooks/useHideBalance';
import { WalletBalanceProvider } from '@/src/hooks/useWalletBalance';
import { InAppNotificationBanner, type NotificationPayload } from '@/components/ui/InAppNotificationBanner';
@@ -157,13 +158,15 @@ export default function RootLayout() {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/mobile_app/components/nodes/PendingCosigns.tsx b/mobile_app/components/nodes/PendingCosigns.tsx
index 0565391e..c4073fe7 100644
--- a/mobile_app/components/nodes/PendingCosigns.tsx
+++ b/mobile_app/components/nodes/PendingCosigns.tsx
@@ -6,6 +6,7 @@ import {
import { Feather, MaterialCommunityIcons } from '@expo/vector-icons';
import { fontFamily, useTheme } from '@/theme';
import { useGlass } from '@/hooks/useGlass';
+import { relTime } from '@/src/utils/relTime';
export interface PendingCosign {
id: string;
@@ -20,13 +21,6 @@ interface Props {
items: PendingCosign[];
}
-function relTime(ms: number) {
- const d = Date.now() - ms;
- if (d < 60_000) return 'just now';
- if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
- return `${Math.floor(d / 3_600_000)}h ago`;
-}
-
function short(hash: string) {
return `${hash.slice(0, 6)}…${hash.slice(-6)}`;
}
diff --git a/mobile_app/components/wallet/SendPanel.tsx b/mobile_app/components/wallet/SendPanel.tsx
index 484a74a9..72cab1e2 100644
--- a/mobile_app/components/wallet/SendPanel.tsx
+++ b/mobile_app/components/wallet/SendPanel.tsx
@@ -1,3 +1,6 @@
+// FUTURE: roadmap preview. Not exported from `components/wallet/index.ts` —
+// re-add the export only after wiring real behavior and wrapping CTAs in
+// . Per AUDIT A6 / ROADMAP § 0.A.8.
import React, { memo, useState, useEffect } from 'react';
import { View, Text, TextInput, Pressable, ScrollView, StyleSheet } from 'react-native';
import { Feather } from '@expo/vector-icons';
diff --git a/mobile_app/components/wallet/SwapPanel.tsx b/mobile_app/components/wallet/SwapPanel.tsx
index db24aebf..41e3ffb3 100644
--- a/mobile_app/components/wallet/SwapPanel.tsx
+++ b/mobile_app/components/wallet/SwapPanel.tsx
@@ -1,3 +1,6 @@
+// FUTURE: roadmap preview. Not exported from `components/wallet/index.ts` —
+// re-add the export only after wiring real behavior and wrapping CTAs in
+// . Per AUDIT A6 / ROADMAP § 0.A.8.
import React, { memo, useState, useEffect } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Feather } from '@expo/vector-icons';
diff --git a/mobile_app/components/wallet/WalletTabs.tsx b/mobile_app/components/wallet/WalletTabs.tsx
index a319dd9c..231f5de8 100644
--- a/mobile_app/components/wallet/WalletTabs.tsx
+++ b/mobile_app/components/wallet/WalletTabs.tsx
@@ -1,3 +1,6 @@
+// FUTURE: roadmap preview. Not exported from `components/wallet/index.ts` —
+// re-add the export only after wiring real behavior and wrapping CTAs in
+// . Per AUDIT A6 / ROADMAP § 0.A.8.
import React, { memo } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Feather } from '@expo/vector-icons';
diff --git a/mobile_app/components/wallet/YieldPanel.tsx b/mobile_app/components/wallet/YieldPanel.tsx
index de3384dd..a4c7626a 100644
--- a/mobile_app/components/wallet/YieldPanel.tsx
+++ b/mobile_app/components/wallet/YieldPanel.tsx
@@ -1,3 +1,6 @@
+// FUTURE: roadmap preview. Not exported from `components/wallet/index.ts` —
+// re-add the export only after wiring real behavior and wrapping CTAs in
+// . Per AUDIT A6 / ROADMAP § 0.A.8.
import React, { memo, useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { fontFamily, useTheme } from '@/theme';
diff --git a/mobile_app/components/wallet/index.ts b/mobile_app/components/wallet/index.ts
index d4f87b45..c3a51ca6 100644
--- a/mobile_app/components/wallet/index.ts
+++ b/mobile_app/components/wallet/index.ts
@@ -1,8 +1,8 @@
export { AssetDot } from './AssetDot';
-export { WalletTabs } from './WalletTabs';
-export { SendPanel } from './SendPanel';
export { ReceivePanel } from './ReceivePanel';
export { SwapRow } from './SwapRow';
-export { SwapPanel } from './SwapPanel';
-export { YieldPanel } from './YieldPanel';
export type { Asset, Tab } from './types';
+
+// FUTURE: WalletTabs, SendPanel, SwapPanel, YieldPanel are roadmap-preview
+// surfaces. Re-export here only when each is wired to live behavior and
+// wrapped in . See AUDIT A6 + ROADMAP § 0.A.8.
diff --git a/mobile_app/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx
index 4d9f89c4..d3b3b9c4 100644
--- a/mobile_app/context/LxmfContext.tsx
+++ b/mobile_app/context/LxmfContext.tsx
@@ -18,6 +18,7 @@ import {
} from '@magicred-1/react-native-lxmf';
import { generateNickname } from '@/components/onboarding/constants';
import { requestBLEPermissions } from '@/src/utils/blePermissions';
+import { sliceNewEvents } from '@/src/utils/sliceNewEvents';
import * as ExpoCrypto from 'expo-crypto';
const IDENTITY_SCHEMA_VERSION = 1;
@@ -130,23 +131,6 @@ function applyAnnounceEvent(
const ANNOUNCE_LOG_RE = /announce from ([0-9a-f]{32}) \((\d+) hops\)/;
-// Events are prepended newest-first and capped at 200. Once capped, length
-// never grows, so we detect new prepended events by reference comparison.
-function sliceNewEvents(
- events: LxmfEvent[],
- prevCount: number,
- prevFirst: LxmfEvent | null,
-): LxmfEvent[] {
- if (events.length > prevCount) return events.slice(0, events.length - prevCount);
- 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 [];
-}
-
// Fallback: parse log events for announces (library compat across versions)
function applyLogAnnounce(
e: LxmfEvent, map: PeerMap, names: NameDict, now: number, ownHash: string | undefined,
diff --git a/mobile_app/context/NetworkModeContext.tsx b/mobile_app/context/NetworkModeContext.tsx
new file mode 100644
index 00000000..c2d26293
--- /dev/null
+++ b/mobile_app/context/NetworkModeContext.tsx
@@ -0,0 +1,140 @@
+import "@/polyfills";
+
+import NetInfo from "@react-native-community/netinfo";
+import React, {
+ createContext,
+ ReactNode,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import { useLxmfContext } from "@/context/LxmfContext";
+import { solanaConnection } from "@/src/infrastructure/network/connection";
+import { DirectRpcAdapter } from "@/src/infrastructure/network/DirectRpcAdapter";
+import { IsolatedRpcAdapter } from "@/src/infrastructure/network/IsolatedRpcAdapter";
+import { MeshRpcAdapter } from "@/src/infrastructure/network/MeshRpcAdapter";
+import type { IRpcAdapter, NetworkMode } from "@/src/infrastructure/network/types";
+
+// Beacon must be active and recently announced to be considered a usable
+// Solana relay route. Plain peers / BLE are mesh presence, not RPC transport.
+const BEACON_STALE_MS = 120_000;
+const EPOCH_MS_THRESHOLD = 10_000_000_000;
+
+function announceMillis(lastAnnounce: number): number {
+ return lastAnnounce > EPOCH_MS_THRESHOLD ? lastAnnounce : lastAnnounce * 1000;
+}
+
+function freshRelayBeacon(
+ beacons: { destHash: string; state: string; lastAnnounce: number }[],
+ ownHash: string | null | undefined,
+) {
+ const now = Date.now();
+ return [...beacons]
+ .filter((b) =>
+ b.state === "active" &&
+ b.destHash !== ownHash &&
+ now - announceMillis(b.lastAnnounce) < BEACON_STALE_MS,
+ )
+ .sort((a, b) => announceMillis(b.lastAnnounce) - announceMillis(a.lastAnnounce))[0] ?? null;
+}
+
+function hasInternetRoute(state: {
+ isConnected: boolean | null;
+ isInternetReachable: boolean | null;
+}) {
+ // On Android/iOS, NetInfo can report isInternetReachable=null while the
+ // device is connected. Treat only explicit false as offline so Solana sends
+ // do not get misrouted to mesh when normal internet is available.
+ return state.isConnected === true && state.isInternetReachable !== false;
+}
+
+export interface NetworkState {
+ mode: NetworkMode;
+ adapter: IRpcAdapter;
+ relayHash: string | null;
+}
+
+const NetworkModeContext = createContext(undefined);
+
+export function NetworkModeProvider({ children }: { children: ReactNode }) {
+ const { beacons, send, events, status } = useLxmfContext();
+ const [internet, setInternet] = useState(true);
+
+ // Subscribe to OS-level connectivity — no polling, no HTTP spam.
+ useEffect(() => {
+ const unsub = NetInfo.addEventListener((state) => {
+ setInternet(hasInternetRoute(state));
+ });
+ NetInfo.fetch().then((state) => {
+ setInternet(hasInternetRoute(state));
+ });
+ return unsub;
+ }, []);
+
+ const relay = useMemo(
+ () => freshRelayBeacon(beacons, status?.addressHex),
+ [beacons, status?.addressHex],
+ );
+
+ let mode: NetworkMode;
+ if (internet) {
+ mode = "online";
+ } else if (relay) {
+ mode = "mesh";
+ } else {
+ mode = "isolated";
+ }
+
+ // Stable adapter ref — single MeshRpcAdapter instance across the app so
+ // pending-request maps + rpcResponse routing don't fragment under
+ // simultaneous mounts. Fixes T23.
+ const meshAdapterRef = useRef(null);
+ const adapter = useMemo(() => {
+ if (mode === "online") {
+ meshAdapterRef.current = null;
+ return new DirectRpcAdapter(solanaConnection);
+ }
+ if (mode === "mesh" && relay) {
+ const a = new MeshRpcAdapter(relay.destHash, send);
+ meshAdapterRef.current = a;
+ return a;
+ }
+ meshAdapterRef.current = null;
+ return new IsolatedRpcAdapter();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [mode, relay?.destHash]);
+
+ // Route incoming LXMF messages + rpcResponse events to the active mesh adapter.
+ useEffect(() => {
+ const mesh = meshAdapterRef.current;
+ if (!mesh || events.length === 0) return;
+ const last = events[0];
+ if (last?.type === "messageReceived" && last.source && last.body) {
+ const decoded = Buffer.from(last.body as string, "base64").toString("utf8");
+ mesh.handleIncoming(last.source as string, decoded);
+ }
+ if (last?.type === "rpcResponse") {
+ mesh.handleIncoming(last.source as string ?? "", last.body as string ?? "");
+ }
+ }, [events]);
+
+ const value = useMemo(
+ () => ({ mode, adapter, relayHash: adapter.relayHash }),
+ [mode, adapter],
+ );
+
+ return (
+ {children}
+ );
+}
+
+export function useNetworkMode(): NetworkState {
+ const ctx = useContext(NetworkModeContext);
+ if (!ctx) {
+ throw new Error("useNetworkMode must be used within NetworkModeProvider");
+ }
+ return ctx;
+}
diff --git a/mobile_app/hooks/useMessageNotifications.ts b/mobile_app/hooks/useMessageNotifications.ts
index 5b979268..9b04de63 100644
--- a/mobile_app/hooks/useMessageNotifications.ts
+++ b/mobile_app/hooks/useMessageNotifications.ts
@@ -3,6 +3,7 @@ import { AppState, Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import type { LxmfEvent } from '@magicred-1/react-native-lxmf';
import { useLxmfContext } from '@/context/LxmfContext';
+import { sliceNewEvents } from '@/src/utils/sliceNewEvents';
import type { NotificationPayload } from '@/components/ui/InAppNotificationBanner';
import { activeConversationRef } from './activeConversation';
import { messagesFocusedRef } from './messagesFocused';
@@ -30,18 +31,6 @@ if (Platform.OS === 'android') {
}).catch(() => {});
}
-// Events are prepended newest-first and capped at 200 — use reference comparison when capped.
-function sliceNewEvents(
- events: LxmfEvent[], prevCount: number, prevFirst: LxmfEvent | null,
-): LxmfEvent[] {
- if (events.length > prevCount) return events.slice(0, events.length - prevCount);
- const first = events[0] ?? null;
- if (prevFirst !== null && first !== prevFirst) {
- const oldIdx = events.indexOf(prevFirst);
- return oldIdx > 0 ? events.slice(0, oldIdx) : [];
- }
- return [];
-}
export function useMessageNotifications(
onInApp: (n: NotificationPayload) => void,
diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx
index 85c60752..7f24c47c 100644
--- a/mobile_app/screens/MessagesScreen.tsx
+++ b/mobile_app/screens/MessagesScreen.tsx
@@ -60,6 +60,7 @@ function decodeBody(raw: string): string {
}
import type { LxmfEvent } from '@magicred-1/react-native-lxmf';
+import { sliceNewEvents } from '@/src/utils/sliceNewEvents';
let _msgId = Date.now();
const nextId = () => ++_msgId;
@@ -77,18 +78,6 @@ function renderMsg(m: AnyMsg, getSendState: GetSendState): React.ReactElement {
return ;
}
-function sliceNewEvents(
- events: LxmfEvent[], prevCount: number, prevFirst: LxmfEvent | null,
-): LxmfEvent[] {
- if (events.length > prevCount) return events.slice(0, events.length - prevCount);
- const first = events[0] ?? null;
- if (prevFirst !== null && first !== prevFirst) {
- const oldIdx = events.indexOf(prevFirst);
- return oldIdx > 0 ? events.slice(0, oldIdx) : [];
- }
- return [];
-}
-
// ── Helpers ──────────────────────────────────────────────────────────────────
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
diff --git a/mobile_app/screens/WalletScreen.tsx b/mobile_app/screens/WalletScreen.tsx
index 7aed8399..09d44858 100644
--- a/mobile_app/screens/WalletScreen.tsx
+++ b/mobile_app/screens/WalletScreen.tsx
@@ -17,6 +17,7 @@ import { useHideBalance } from '@/src/hooks/useHideBalance';
import { useWalletBalance } from '@/src/hooks/useWalletBalance';
import { useNetworkMode } from '@/src/hooks/useNetworkMode';
import type { ActivityEntry, TokenBalance } from '@/src/services/walletData';
+import { relTime } from '@/src/utils/relTime';
import { fontFamily, useTheme } from '@/theme';
// ── helpers ───────────────────────────────────────────────────────────────────
@@ -41,14 +42,6 @@ const TOKEN_COLOR: Record = {
JUP: '#C7F284', BONK: '#FFB020',
};
-function relTime(ms: number) {
- const d = Date.now() - ms;
- if (d < 60_000) return 'just now';
- if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
- if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
- return `${Math.floor(d / 86_400_000)}d ago`;
-}
-
// ── tiles ─────────────────────────────────────────────────────────────────────
function BalanceTile({ hidden, toggle }: { readonly hidden: boolean; readonly toggle: () => void }) {
diff --git a/mobile_app/src/hooks/useNetworkMode.ts b/mobile_app/src/hooks/useNetworkMode.ts
index 684b7b31..358ad8f3 100644
--- a/mobile_app/src/hooks/useNetworkMode.ts
+++ b/mobile_app/src/hooks/useNetworkMode.ts
@@ -1,110 +1,5 @@
-import "@/polyfills";
-
-import NetInfo from '@react-native-community/netinfo';
-import { useEffect, useMemo, useRef, useState } from 'react';
-import { useLxmfContext } from '@/context/LxmfContext';
-import { solanaConnection } from '@/src/services/sendTransaction';
-import { DirectRpcAdapter } from '../infrastructure/network/DirectRpcAdapter';
-import { IsolatedRpcAdapter } from '../infrastructure/network/IsolatedRpcAdapter';
-import { MeshRpcAdapter } from '../infrastructure/network/MeshRpcAdapter';
-import type { IRpcAdapter, NetworkMode } from '../infrastructure/network/types';
-
-// Beacon must be active and recently announced to be considered a usable
-// Solana relay route. Plain peers/BLE are mesh presence, not RPC transport.
-const BEACON_STALE_MS = 120_000;
-const EPOCH_MS_THRESHOLD = 10_000_000_000;
-
-function announceMillis(lastAnnounce: number): number {
- return lastAnnounce > EPOCH_MS_THRESHOLD ? lastAnnounce : lastAnnounce * 1000;
-}
-
-function freshRelayBeacon(
- beacons: { destHash: string; state: string; lastAnnounce: number }[],
- ownHash: string | null | undefined,
-) {
- const now = Date.now();
- return [...beacons]
- .filter((b) =>
- b.state === "active" &&
- b.destHash !== ownHash &&
- now - announceMillis(b.lastAnnounce) < BEACON_STALE_MS,
- )
- .sort((a, b) => announceMillis(b.lastAnnounce) - announceMillis(a.lastAnnounce))[0] ?? null;
-}
-
-function hasInternetRoute(state: {
- isConnected: boolean | null;
- isInternetReachable: boolean | null;
-}) {
- // On Android/iOS, NetInfo can report isInternetReachable=null while the
- // device is connected. Treat only explicit false as offline so Solana sends
- // do not get misrouted to mesh when normal internet is available.
- return state.isConnected === true && state.isInternetReachable !== false;
-}
-
-export interface NetworkState {
- mode: NetworkMode;
- adapter: IRpcAdapter;
- relayHash: string | null;
-}
-
-export function useNetworkMode(): NetworkState {
- const { beacons, send, events, status } = useLxmfContext();
- const [internet, setInternet] = useState(true);
-
- // Subscribe to OS-level connectivity — no polling, no HTTP spam.
- useEffect(() => {
- const unsub = NetInfo.addEventListener((state) => {
- setInternet(hasInternetRoute(state));
- });
- // Fetch once on mount so initial state is correct before first event.
- NetInfo.fetch().then((state) => {
- setInternet(hasInternetRoute(state));
- });
- return unsub;
- }, []);
-
- const relay = useMemo(() => freshRelayBeacon(beacons, status?.addressHex), [beacons, status?.addressHex]);
-
- let mode: NetworkMode;
- if (internet) {
- mode = 'online';
- } else if (relay) {
- mode = 'mesh';
- } else {
- mode = 'isolated';
- }
-
- // Stable adapter refs — recreate only when mode or relay changes.
- const meshAdapterRef = useRef(null);
- const adapter = useMemo(() => {
- if (mode === 'online') {
- meshAdapterRef.current = null;
- return new DirectRpcAdapter(solanaConnection);
- }
- if (mode === 'mesh' && relay) {
- const a = new MeshRpcAdapter(relay.destHash, send);
- meshAdapterRef.current = a;
- return a;
- }
- meshAdapterRef.current = null;
- return new IsolatedRpcAdapter();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [mode, relay?.destHash]);
-
- // Route incoming LXMF messages and rpcResponse events to the active MeshRpcAdapter.
- useEffect(() => {
- const mesh = meshAdapterRef.current;
- if (!mesh || events.length === 0) return;
- const last = events[0];
- if (last?.type === 'messageReceived' && last.source && last.body) {
- const decoded = Buffer.from(last.body as string, 'base64').toString('utf8');
- mesh.handleIncoming(last.source as string, decoded);
- }
- if (last?.type === 'rpcResponse') {
- mesh.handleIncoming(last.source as string ?? '', last.body as string ?? '');
- }
- }, [events]);
-
- return { mode, adapter, relayHash: adapter.relayHash };
-}
+// useNetworkMode lives in `context/NetworkModeContext` so the MeshRpcAdapter
+// + NetInfo subscription stay singletons across the app. This module
+// re-exports the hook + types so existing import paths keep working.
+export { useNetworkMode, NetworkModeProvider } from "@/context/NetworkModeContext";
+export type { NetworkState } from "@/context/NetworkModeContext";
diff --git a/mobile_app/src/hooks/useWalletBalance.tsx b/mobile_app/src/hooks/useWalletBalance.tsx
index 336d110f..ec355347 100644
--- a/mobile_app/src/hooks/useWalletBalance.tsx
+++ b/mobile_app/src/hooks/useWalletBalance.tsx
@@ -2,7 +2,7 @@ import React, { createContext, ReactNode, useCallback, useContext, useEffect, us
import { useWallet } from "@/context/WalletContext";
import { useNetworkMode } from "@/src/hooks/useNetworkMode";
-import { solanaConnection } from "@/src/services/sendTransaction";
+import { solanaConnection } from "@/src/infrastructure/network/connection";
import {
ActivityEntry,
SOL_DECIMALS,
diff --git a/mobile_app/src/infrastructure/network/connection.ts b/mobile_app/src/infrastructure/network/connection.ts
new file mode 100644
index 00000000..868eb33f
--- /dev/null
+++ b/mobile_app/src/infrastructure/network/connection.ts
@@ -0,0 +1,15 @@
+import "@/polyfills";
+
+import { Connection } from "@solana/web3.js";
+
+// Devnet-only for safety. Mainnet wiring is a deliberate future decision —
+// we don't want mainnet funds going out via a dev build.
+//
+// EXPO_PUBLIC_SOLANA_RPC lets teams point at a dedicated devnet endpoint
+// (Helius / QuickNode / Triton free tier) to avoid the public endpoint's
+// 429 rate-limits. Falls back to the public endpoint when unset so cloning
+// the repo "just works".
+const DEFAULT_DEVNET_RPC = "https://api.devnet.solana.com";
+const RPC_URL = process.env.EXPO_PUBLIC_SOLANA_RPC || DEFAULT_DEVNET_RPC;
+
+export const solanaConnection = new Connection(RPC_URL, "confirmed");
diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts
index 3c6b13fb..cff9a62a 100644
--- a/mobile_app/src/services/sendTransaction.ts
+++ b/mobile_app/src/services/sendTransaction.ts
@@ -1,7 +1,6 @@
import "@/polyfills";
import {
- Connection,
Keypair,
PublicKey,
SystemProgram,
@@ -16,6 +15,7 @@ import { transact } from "@solana-mobile/mobile-wallet-adapter-protocol-web3js";
import { Buffer } from "buffer";
import type { IRpcAdapter } from "@/src/infrastructure/network";
+import { solanaConnection } from "@/src/infrastructure/network/connection";
import type { IWalletAdapter } from "@/src/infrastructure/wallet";
import { buildDevnetExplorerTxUrl } from "@/src/services/explorer";
import { assertSendableSplProgram } from "@/src/services/walletData";
@@ -29,17 +29,10 @@ const APP_IDENTITY = {
icon: "/favicon.ico",
};
-// Devnet-only for safety. Mainnet wiring is a deliberate future
-// decision — we don't want mainnet funds going out via a dev build.
-//
-// EXPO_PUBLIC_SOLANA_RPC lets teams point at a dedicated devnet
-// endpoint (Helius / QuickNode / Triton free tier) to avoid the
-// public endpoint's 429 rate-limits. Falls back to the public
-// endpoint when unset so cloning the repo "just works".
-const DEFAULT_DEVNET_RPC = "https://api.devnet.solana.com";
-const RPC_URL = process.env.EXPO_PUBLIC_SOLANA_RPC || DEFAULT_DEVNET_RPC;
-
-export const solanaConnection = new Connection(RPC_URL, "confirmed");
+// Re-export so existing consumers (`@/src/services/sendTransaction`) keep
+// working without import churn. The singleton lives in
+// `src/infrastructure/network/connection.ts`.
+export { solanaConnection };
export interface SendSolParams {
walletAdapter: IWalletAdapter;
diff --git a/mobile_app/src/utils/relTime.ts b/mobile_app/src/utils/relTime.ts
new file mode 100644
index 00000000..4ff85d54
--- /dev/null
+++ b/mobile_app/src/utils/relTime.ts
@@ -0,0 +1,12 @@
+/**
+ * Format a unix epoch in ms as "5m ago" / "2h ago" / "3d ago".
+ *
+ * Returns "just now" under 60s.
+ */
+export function relTime(ms: number): string {
+ const d = Date.now() - ms;
+ if (d < 60_000) return "just now";
+ if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
+ if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
+ return `${Math.floor(d / 86_400_000)}d ago`;
+}
diff --git a/mobile_app/src/utils/sliceNewEvents.ts b/mobile_app/src/utils/sliceNewEvents.ts
new file mode 100644
index 00000000..48aeebc1
--- /dev/null
+++ b/mobile_app/src/utils/sliceNewEvents.ts
@@ -0,0 +1,27 @@
+import type { LxmfEvent } from "@magicred-1/react-native-lxmf";
+
+/**
+ * The LXMF event log is prepended newest-first and capped at 200 entries. Once
+ * the cap is hit length stops growing, so callers track the previous head by
+ * reference to detect new prepended events.
+ *
+ * Three semantics for the return value:
+ * - length grew → return the prefix slice that's new
+ * - length capped but head moved → return the prefix up to the previous head
+ * (or everything if the previous head fell off the end)
+ * - head unchanged → return []
+ */
+export function sliceNewEvents(
+ events: LxmfEvent[],
+ prevCount: number,
+ prevFirst: LxmfEvent | null,
+): LxmfEvent[] {
+ if (events.length > prevCount) return events.slice(0, events.length - prevCount);
+ 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 [];
+}