From 76f9f0db5620aaf534d84c9c76d4fd0fafd4b7c7 Mon Sep 17 00:00:00 2001 From: r1n04h Date: Mon, 25 May 2026 22:03:35 +0100 Subject: [PATCH] refactor: ai account number --- .agents/swap.md | 2 +- mobile/app/(tabs)/_layout.tsx | 3 +- mobile/app/Home.tsx | 3 +- mobile/app/PocketSwitch.tsx | 28 ++++++------- mobile/components/StickyHeader.tsx | 4 +- mobile/src/features/mcp/README.md | 4 +- mobile/src/features/mcp/modules/mcp-calls.ts | 3 +- .../src/features/mcp/modules/mcp-constants.ts | 3 -- .../src/tests/unit-vi/mcp-calls-swap.test.ts | 14 +++---- shared/hooks/AccountNumberContext.tsx | 42 +++++++------------ 10 files changed, 45 insertions(+), 61 deletions(-) diff --git a/.agents/swap.md b/.agents/swap.md index d741e6ab..0bd286f3 100644 --- a/.agents/swap.md +++ b/.agents/swap.md @@ -158,7 +158,7 @@ getTrackingUrl?(execution): string | undefined ## MCP swap surface (`mobile/src/features/mcp/modules/mcp-calls.ts`) -Two tools expose Flashnet to remote AI agents. They run on `MCP_BALANCE_ACCOUNT_NUMBER` (= 4) so they don't touch the user's primary account. +Two tools expose Flashnet to remote AI agents. They run on `MCP_BALANCE_ACCOUNT_NUMBER` (= 4141) so they don't touch the user's primary account. - **`get_swap_quote(send_asset, receive_asset, send_amount_base_units)`** — `send_asset` / `receive_asset` are strict `AssetId` strings (currently `native:spark` / `token:spark:usdb`). Internally: `lazyInitWallet(NETWORK_SPARK, 4)` → `setFlashnetAccountNumber(4)` → `manager.getQuote()` → `manager.executeTransfer()` (Flashnet: stages params, no funds movement — in-memory only, NOT persisted). Returns `{ quote_id, send_amount_base_units, receive_amount_base_units, fee_base_units, fee_asset, fee_ticker, price_impact_pct, rate, estimated_time_seconds, expires_at_unix, service }`. - **`execute_swap(quote_id)`** — `manager.executeInstantSwap(quote_id)` → `commitTransfer()` (persists completed row). The manager looks up the owning service from `executionOwners` (populated in `executeTransfer`), so the agent only needs `quote_id`. Idempotency: the manager pops the owner entry on execute and the owning service pops the quote from its pending map; replay fails with _"No pending swap found"_. Quote expiry is enforced by Flashnet's internal `PENDING_SWAP_TTL` (5 min) on top of `TransferQuote.expiresAt` (60 s). diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index b6b7e3a4..fc8414ec 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -5,8 +5,7 @@ import React, { useContext } from 'react'; import { Platform, StyleSheet } from 'react-native'; import CustomTabBarBackground from '@/components/ui/CustomTabBarBackground'; -import { MCP_BALANCE_ACCOUNT_NUMBER } from '@/src/features/mcp/modules/mcp-constants'; -import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; +import { AccountNumberContext, MCP_BALANCE_ACCOUNT_NUMBER } from '@shared/hooks/AccountNumberContext'; // Use custom tab bar only on iOS 18–25 for a reliable background. iOS 26+ and Android use native tabs. const iosVersion = Platform.OS === 'ios' ? (typeof Platform.Version === 'string' ? parseInt(String(Platform.Version), 10) : Number(Platform.Version)) : 0; diff --git a/mobile/app/Home.tsx b/mobile/app/Home.tsx index a3073532..ef8969d0 100644 --- a/mobile/app/Home.tsx +++ b/mobile/app/Home.tsx @@ -27,10 +27,9 @@ import TokensView from '@/components/TokensView'; import YieldView from '@/components/YieldView'; import TransactionsList from '@/components/TransactionsList'; import { BackgroundExecutor } from '@/src/modules/background-executor'; -import { MCP_BALANCE_ACCOUNT_NUMBER } from '@/src/features/mcp/modules/mcp-constants'; import { getNetworkImageAsset } from '@/utils/networkAssets'; import { getNetworkGradient } from '@shared/constants/Colors'; -import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; +import { AccountNumberContext, MCP_BALANCE_ACCOUNT_NUMBER } from '@shared/hooks/AccountNumberContext'; import { NetworkContext } from '@shared/hooks/NetworkContext'; import { useAvailableNetworks } from '@shared/hooks/useAvailableNetworks'; import { useSettings } from '@shared/hooks/useSettings'; diff --git a/mobile/app/PocketSwitch.tsx b/mobile/app/PocketSwitch.tsx index 9155383c..6613b0d1 100644 --- a/mobile/app/PocketSwitch.tsx +++ b/mobile/app/PocketSwitch.tsx @@ -22,16 +22,16 @@ const TotalBalanceSection = () => { const { exchangeRate } = useExchangeRate(NETWORK_BITCOIN, 'USD'); // Get balances for all accounts (hooks must be called unconditionally) - const { accountBalance: balance0 } = useAccountBalance(0, availableNetworks); - const { accountBalance: balance1 } = useAccountBalance(1, availableNetworks); - const { accountBalance: balance2 } = useAccountBalance(2, availableNetworks); - const { accountBalance: balance3 } = useAccountBalance(3, availableNetworks); - const { accountBalance: balance4 } = useAccountBalance(4, availableNetworks); + const { accountBalance: balance0 } = useAccountBalance(accountItems[0].accountNumber, availableNetworks); + const { accountBalance: balance1 } = useAccountBalance(accountItems[1].accountNumber, availableNetworks); + const { accountBalance: balance2 } = useAccountBalance(accountItems[2].accountNumber, availableNetworks); + const { accountBalance: balance3 } = useAccountBalance(accountItems[3].accountNumber, availableNetworks); + const { accountBalance: balanceMcp } = useAccountBalance(accountItems[4].accountNumber, availableNetworks); const totalBalance = useMemo(() => { - const balances = [balance0, balance1, balance2, balance3, balance4].slice(0, accountItems.length); + const balances = [balance0, balance1, balance2, balance3, balanceMcp].slice(0, accountItems.length); return balances.reduce((sum, bal) => sum + (parseInt(bal) || 0), 0).toString(); - }, [balance0, balance1, balance2, balance3, balance4]); + }, [balance0, balance1, balance2, balance3, balanceMcp]); const totalUsd = totalBalance && exchangeRate ? formatFiatBalance(totalBalance, getDecimalsByNetwork(NETWORK_BITCOIN), exchangeRate) : '—'; @@ -44,13 +44,13 @@ const TotalBalanceSection = () => { ); }; -const ListItem = ({ item, onPress, accountNumber, currentAccountNumber }: { item: AccountItem; onPress: () => void; accountNumber: number; currentAccountNumber: number }) => { +const ListItem = ({ item, onPress, currentAccountNumber }: { item: AccountItem; onPress: () => void; currentAccountNumber: number }) => { const availableNetworks = useAvailableNetworks(); const IconComponent = item.iconCollection === 'ion' ? Ionicons : item.iconCollection === 'material-community' ? MaterialCommunityIcons : Foundation; - const { accountBalance } = useAccountBalance(accountNumber, availableNetworks); + const { accountBalance } = useAccountBalance(item.accountNumber, availableNetworks); const { exchangeRate } = useExchangeRate(NETWORK_BITCOIN, 'USD'); - const active = accountNumber === currentAccountNumber; + const active = item.accountNumber === currentAccountNumber; const usdBalance = accountBalance && exchangeRate ? formatFiatBalance(accountBalance, getDecimalsByNetwork(NETWORK_BITCOIN), exchangeRate) : '—'; @@ -81,8 +81,8 @@ export default function PocketSwitch() { router.back(); }; - const handleSelect = (index: number) => { - setAccountNumber(index); + const handleSelect = (accountNumber: number) => { + setAccountNumber(accountNumber); router.back(); }; @@ -99,8 +99,8 @@ export default function PocketSwitch() { {/* Target Networks List */} - {accountItems.map((item, index) => ( - handleSelect(index)} /> + {accountItems.map((item) => ( + handleSelect(item.accountNumber)} /> ))} diff --git a/mobile/components/StickyHeader.tsx b/mobile/components/StickyHeader.tsx index e0598325..75691b78 100644 --- a/mobile/components/StickyHeader.tsx +++ b/mobile/components/StickyHeader.tsx @@ -3,7 +3,7 @@ import { StyleSheet, View } from 'react-native'; import { useRouter } from 'expo-router'; import { ThemedText } from './ThemedText'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { AccountItem, AccountNumberContext, accountItems } from '@shared/hooks/AccountNumberContext'; +import { AccountNumberContext, getAccountItem } from '@shared/hooks/AccountNumberContext'; import { ScanQrContext } from '@/src/hooks/ScanQrContext'; import { handleQrIntent } from '@/src/modules/scan-routing'; import { Ionicons, Foundation, MaterialCommunityIcons } from '@expo/vector-icons'; @@ -21,7 +21,7 @@ const StickyHeader: React.FC = ({ scrollY, onSettingsPress }) const router = useRouter(); const insets = useSafeAreaInsets(); const { accountNumber } = React.useContext(AccountNumberContext); - const accountItem: AccountItem = accountItems[accountNumber]; + const accountItem = getAccountItem(accountNumber); const { scanQr } = useContext(ScanQrContext); // Animated border opacity based on scroll position diff --git a/mobile/src/features/mcp/README.md b/mobile/src/features/mcp/README.md index 1c520560..9006816f 100644 --- a/mobile/src/features/mcp/README.md +++ b/mobile/src/features/mcp/README.md @@ -48,7 +48,7 @@ features/mcp/ ├── mcp-calls.ts ← Tool registrations (the wallet API surface) ├── tunnel.ts ← WebSocket client + auto-reconnect ├── mcp-activity-log.ts ← In-memory store for the last 5 actions (UI) - └── mcp-constants.ts ← Shared constants (MCP_BALANCE_ACCOUNT_NUMBER, ...) + └── mcp-constants.ts ← MCP-only constants (e.g. lightning fee cap) ``` `components/toast-config.tsx` lives at app-level `mobile/components/` — it's shared across features. @@ -101,7 +101,7 @@ Conventions: - **Always** call `showMcpSuccessToast(summary, detail?)` on success — it both shows the user-visible dark toast (`type: 'mcpAiSuccess'`) and pushes the activity-log line under the Home status row. The toast is the user's only signal that the LLM did something. - Return shape is fixed: `{ content: [{ type: 'text', text: }] }` on success, plus `isError: true` on failure. - All amounts cross the wire as **smallest-unit integer strings** (e.g. satoshis, not BTC). Use `mcpPositiveBaseUnitsString` and let the LLM resolve decimals via `get_network_balance` (returns `decimals`). -- Wallets always use `MCP_BALANCE_ACCOUNT_NUMBER` (4) — the dedicated MCP pocket. Don't read the user's current `accountNumber` context. +- Wallets always use `MCP_BALANCE_ACCOUNT_NUMBER` from `@shared/hooks/AccountNumberContext` (4141) — the dedicated MCP pocket. Don't read the user's current `accountNumber` context. - The `network` argument is always **mainnet-only** for the user-facing surface (`mcpListableNetworks()` filters testnets / lightning aliases / USDT). ## Security model diff --git a/mobile/src/features/mcp/modules/mcp-calls.ts b/mobile/src/features/mcp/modules/mcp-calls.ts index eb15d818..45f83c50 100644 --- a/mobile/src/features/mcp/modules/mcp-calls.ts +++ b/mobile/src/features/mcp/modules/mcp-calls.ts @@ -13,6 +13,7 @@ import Toast from 'react-native-toast-message'; import { walletCanHaveNfts } from '@shared/class/wallets/interface-can-have-nfts'; import { walletCanHaveTokens } from '@shared/class/wallets/interface-can-have-tokens'; import { walletSupportsLightning } from '@shared/class/wallets/interface-lightning-wallet'; +import { MCP_BALANCE_ACCOUNT_NUMBER } from '@shared/hooks/AccountNumberContext'; import { exchangeRateFetcher } from '@shared/hooks/useExchangeRate'; import { balanceFetcher } from '@shared/hooks/useBalance'; import { getTransferServiceManager, setFlashnetAccountNumber, useTransferService } from '@shared/hooks/useTransferService'; @@ -39,7 +40,7 @@ import { BackgroundExecutor } from '@/src/modules/background-executor'; import { AnalyticsEvents, trackAnalyticsEvent } from '@/src/modules/analytics'; import { pushMcpActivityLog } from './mcp-activity-log'; -import { MCP_BALANCE_ACCOUNT_NUMBER, MCP_LIGHTNING_PAY_MAX_FEE_PERCENT } from './mcp-constants'; +import { MCP_LIGHTNING_PAY_MAX_FEE_PERCENT } from './mcp-constants'; function mcpCallLog(line: string): void { console.log('[mcp-call] ' + line); diff --git a/mobile/src/features/mcp/modules/mcp-constants.ts b/mobile/src/features/mcp/modules/mcp-constants.ts index 1ff1d6ff..87a295e1 100644 --- a/mobile/src/features/mcp/modules/mcp-constants.ts +++ b/mobile/src/features/mcp/modules/mcp-constants.ts @@ -1,5 +1,2 @@ -/** MCP wallet tools + Home tunnel row use this account index (keep in sync with `mcp-calls`). */ -export const MCP_BALANCE_ACCOUNT_NUMBER = 4; - /** Matches in-app Lightning send confirm (`send-confirm-lightning.tsx`). */ export const MCP_LIGHTNING_PAY_MAX_FEE_PERCENT = 5; diff --git a/mobile/src/tests/unit-vi/mcp-calls-swap.test.ts b/mobile/src/tests/unit-vi/mcp-calls-swap.test.ts index f90ad025..02da2c5c 100644 --- a/mobile/src/tests/unit-vi/mcp-calls-swap.test.ts +++ b/mobile/src/tests/unit-vi/mcp-calls-swap.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MCP_BALANCE_ACCOUNT_NUMBER } from '@shared/hooks/AccountNumberContext'; import { registerWalletMcpCalls } from '../../features/mcp/modules/mcp-calls'; import { FlashnetTransferService } from '@shared/services/transfer-service-flashnet'; import { TransferServiceManager } from '@shared/services/transfer-service-manager'; @@ -17,7 +18,6 @@ import { TransferServiceManager } from '@shared/services/transfer-service-manage const BTC_PUBKEY = '020202020202020202020202020202020202020202020202020202020202020202'; const USDB_PUBKEY = '3206c93b24a4d18ea19d0a9a213204af2c7e74a6d16c7535cc5d33eca4ad1eca'; const POOL_ID = 'pool-btc-usdb'; -const MCP_ACCOUNT = 4; // vi.hoisted — vi.mock factories are lifted above imports, so their closures need // stable references that exist at hoist time. SDK call spies live here so we can @@ -217,7 +217,7 @@ describe('MCP swap tools', () => { }); describe('get_swap_quote — wiring guarantees', () => { - it('pins all swap activity to MCP_BALANCE_ACCOUNT_NUMBER (4), even if a UI flow set a different one', async () => { + it('pins all swap activity to MCP_BALANCE_ACCOUNT_NUMBER, even if a UI flow set a different one', async () => { // Pretend the UI was on account 7 right before the agent came in. flashnet.setCurrentAccountNumber(7); @@ -228,11 +228,11 @@ describe('MCP swap tools', () => { }); // Spark wallet for the MCP account must have been initialized. - expect(lazyInitWallet).toHaveBeenCalledWith('spark', MCP_ACCOUNT); + expect(lazyInitWallet).toHaveBeenCalledWith('spark', MCP_BALANCE_ACCOUNT_NUMBER); // FlashnetTransferService.ensureClient resolves its wallet via getSparkWallet(currentAccountNumber). - // If the wrapper had failed to re-point the service at MCP_ACCOUNT, this would be called with 7. - expect(getSparkWallet).toHaveBeenCalledWith(MCP_ACCOUNT); + // If the wrapper had failed to re-point the service at MCP_BALANCE_ACCOUNT_NUMBER, this would be called with 7. + expect(getSparkWallet).toHaveBeenCalledWith(MCP_BALANCE_ACCOUNT_NUMBER); expect(getSparkWallet).not.toHaveBeenCalledWith(7); }); @@ -422,8 +422,8 @@ describe('MCP swap tools', () => { await handlers.get('execute_swap')!({ quote_id: quoteId }); - // Execute must have asked for account 4's wallet, not 9. - expect(getSparkWallet).toHaveBeenCalledWith(MCP_ACCOUNT); + // Execute must have asked for the MCP pocket wallet, not 9. + expect(getSparkWallet).toHaveBeenCalledWith(MCP_BALANCE_ACCOUNT_NUMBER); expect(getSparkWallet).not.toHaveBeenCalledWith(9); }); diff --git a/shared/hooks/AccountNumberContext.tsx b/shared/hooks/AccountNumberContext.tsx index 20f721e8..7ae7ae3e 100644 --- a/shared/hooks/AccountNumberContext.tsx +++ b/shared/hooks/AccountNumberContext.tsx @@ -7,40 +7,28 @@ import { Networks } from '../types/networks'; import { STORAGE_SELECTED_NETWORK } from './NetworkContext'; import { IStorage } from '../types/IStorage'; +/** Dedicated wallet account index for the AI Agent / MCP pocket (BIP account'). */ +export const MCP_BALANCE_ACCOUNT_NUMBER = 4141; + export interface AccountItem { + accountNumber: number; name: string; icon: string; iconCollection: string; } -export const accountItems: AccountItem[] = [ - { - name: 'Daily', - icon: 'wallet-outline', - iconCollection: 'ion', - }, - { - name: 'Investment', - icon: 'bar-chart-outline', - iconCollection: 'ion', - }, - { - name: 'Lifestyle', - icon: 'cart-outline', - iconCollection: 'ion', - }, - { - name: 'Emergency', - icon: 'sound', - iconCollection: 'foundation', - }, - { - name: 'AI Agent', - icon: 'robot-outline', - iconCollection: 'material-community', - }, +export const accountItems: readonly AccountItem[] = [ + { accountNumber: 0, name: 'Daily', icon: 'wallet-outline', iconCollection: 'ion' }, + { accountNumber: 1, name: 'Investment', icon: 'bar-chart-outline', iconCollection: 'ion' }, + { accountNumber: 2, name: 'Lifestyle', icon: 'cart-outline', iconCollection: 'ion' }, + { accountNumber: 3, name: 'Emergency', icon: 'sound', iconCollection: 'foundation' }, + { accountNumber: MCP_BALANCE_ACCOUNT_NUMBER, name: 'AI Agent', icon: 'robot-outline', iconCollection: 'material-community' }, ] as const; +export function getAccountItem(accountNumber: number): AccountItem { + return accountItems.find((item) => item.accountNumber === accountNumber) ?? accountItems[0]; +} + type AccountNumber = number; interface IAccountNumberContext { @@ -92,7 +80,7 @@ export const AccountNumberContextProvider: React.FC { try { const response = (await props.storage.getItem(STORAGE_SELECTED_NETWORK)) as Networks; - const addressResponse = await props.backgroundCaller.getAddress(response || DEFAULT_NETWORK, accountNumber); + const addressResponse = await props.backgroundCaller.getAddress(response || DEFAULT_NETWORK, value); await props.messenger.sendEventCallbackFromPopupToContentScript({ for: 'webpage', event: 'accountsChanged',