Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/swap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 1 addition & 2 deletions mobile/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions mobile/app/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
28 changes: 14 additions & 14 deletions mobile/app/PocketSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) : '—';

Expand All @@ -44,13 +44,13 @@ const TotalBalanceSection = () => {
</View>
);
};
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) : '—';

Expand Down Expand Up @@ -81,8 +81,8 @@ export default function PocketSwitch() {
router.back();
};

const handleSelect = (index: number) => {
setAccountNumber(index);
const handleSelect = (accountNumber: number) => {
setAccountNumber(accountNumber);
router.back();
};

Expand All @@ -99,8 +99,8 @@ export default function PocketSwitch() {
</View>
{/* Target Networks List */}
<View style={styles.listContainer}>
{accountItems.map((item, index) => (
<ListItem key={index} accountNumber={index} currentAccountNumber={currentAccountNumber} item={item} onPress={() => handleSelect(index)} />
{accountItems.map((item) => (
<ListItem key={item.accountNumber} item={item} currentAccountNumber={currentAccountNumber} onPress={() => handleSelect(item.accountNumber)} />
))}
</View>
</View>
Expand Down
4 changes: 2 additions & 2 deletions mobile/components/StickyHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,7 +21,7 @@ const StickyHeader: React.FC<StickyHeaderProps> = ({ 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
Expand Down
4 changes: 2 additions & 2 deletions mobile/src/features/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: <stringified-json> }] }` 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
Expand Down
3 changes: 2 additions & 1 deletion mobile/src/features/mcp/modules/mcp-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
3 changes: 0 additions & 3 deletions mobile/src/features/mcp/modules/mcp-constants.ts
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 7 additions & 7 deletions mobile/src/tests/unit-vi/mcp-calls-swap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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);

Expand All @@ -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);
});

Expand Down Expand Up @@ -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);
});

Expand Down
42 changes: 15 additions & 27 deletions shared/hooks/AccountNumberContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -92,7 +80,7 @@ export const AccountNumberContextProvider: React.FC<AccountNumberContextProvider
(async () => {
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',
Expand Down
Loading