diff --git a/app/(protected)/_layout.tsx b/app/(protected)/_layout.tsx
index 3227acc5..16643d78 100644
--- a/app/(protected)/_layout.tsx
+++ b/app/(protected)/_layout.tsx
@@ -222,6 +222,12 @@ export default function ProtectedLayout() {
animation: 'slide_from_right',
}}
/>
+
);
}
diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx
new file mode 100644
index 00000000..12f501c3
--- /dev/null
+++ b/app/(protected)/agent/index.tsx
@@ -0,0 +1,404 @@
+import { useState } from 'react';
+import { ActivityIndicator, Pressable, View } from 'react-native';
+import Toast from 'react-native-toast-message';
+import * as Clipboard from 'expo-clipboard';
+import { LinearGradient } from 'expo-linear-gradient';
+import { useLocalSearchParams } from 'expo-router';
+import { FileText, KeyRound, Plus } from 'lucide-react-native';
+
+import AgentDepositModal from '@/components/Agent/AgentDepositModal';
+import ApiKeyList from '@/components/Agent/ApiKeyList';
+import ApiKeyRevealModal from '@/components/Agent/ApiKeyRevealModal';
+import IntegrationSnippet from '@/components/Agent/IntegrationSnippet';
+import CopyToClipboard from '@/components/CopyToClipboard';
+import PageLayout from '@/components/PageLayout';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { buildAgentPromptTemplate } from '@/constants/agentPromptTemplate';
+import {
+ useAgentApiKeys,
+ useAgentBalance,
+ useAgentQuery,
+ useGenerateAgentApiKey,
+ useProvisionAgent,
+ useRevokeAgentApiKey,
+} from '@/hooks/useAgent';
+import { useDimension } from '@/hooks/useDimension';
+import { EXPO_PUBLIC_FLASH_API_BASE_URL, isProduction } from '@/lib/config';
+import { eclipseAddress } from '@/lib/utils';
+
+const formatUsdc = (raw?: bigint) => {
+ if (raw === undefined) return '—';
+ const value = Number(raw) / 1_000_000;
+ return `$${value.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}`;
+};
+
+/**
+ * Render-state override for visual debugging. Pass via query param:
+ * /agent?status=loading
+ * /agent?status=not_provisioned
+ * /agent?status=provisioned
+ * /agent?status=deposited (provisioned + non-zero balance)
+ *
+ * Disabled in production builds — the param is ignored when isProduction.
+ */
+type AgentStatusOverride = 'loading' | 'not_provisioned' | 'provisioned' | 'deposited';
+const VALID_OVERRIDES: AgentStatusOverride[] = [
+ 'loading',
+ 'not_provisioned',
+ 'provisioned',
+ 'deposited',
+];
+const DEMO_AGENT_ADDRESS = '0x0000000000000000000000000000000000000000';
+const DEMO_BALANCE_USDC = 12_345_670n; // $12.34
+
+const handleCopyPrompt = async () => {
+ const template = buildAgentPromptTemplate({ baseUrl: EXPO_PUBLIC_FLASH_API_BASE_URL });
+ await Clipboard.setStringAsync(template);
+ Toast.show({
+ type: 'success',
+ text1: 'Prompt template copied',
+ text2: 'Paste into Claude Desktop or ChatGPT instructions',
+ props: { badgeText: 'Copied' },
+ });
+};
+
+export default function AgentPage() {
+ const { status } = useLocalSearchParams<{ status?: string }>();
+ const { isScreenMedium } = useDimension();
+ const statusOverride: AgentStatusOverride | undefined =
+ !isProduction && VALID_OVERRIDES.includes(status as AgentStatusOverride)
+ ? (status as AgentStatusOverride)
+ : undefined;
+
+ const agentQuery = useAgentQuery();
+ const provision = useProvisionAgent();
+ const apiKeysQuery = useAgentApiKeys();
+ const generateApiKey = useGenerateAgentApiKey();
+ const revokeApiKey = useRevokeAgentApiKey();
+
+ const liveAgent = agentQuery.data;
+ const liveIsProvisioned = !!liveAgent?.agentEoaAddress;
+
+ const isLoading =
+ statusOverride === 'loading' || (statusOverride === undefined && agentQuery.isLoading);
+ const isProvisioned =
+ statusOverride === 'provisioned' ||
+ statusOverride === 'deposited' ||
+ (statusOverride === undefined && liveIsProvisioned);
+ const agentEoaAddress: string | undefined =
+ statusOverride === 'provisioned' || statusOverride === 'deposited'
+ ? DEMO_AGENT_ADDRESS
+ : liveAgent?.agentEoaAddress;
+
+ const balanceQuery = useAgentBalance(agentEoaAddress);
+ const balance =
+ statusOverride === 'deposited' || statusOverride === 'provisioned'
+ ? DEMO_BALANCE_USDC
+ : balanceQuery.data;
+ const balanceLoading = statusOverride === undefined ? balanceQuery.isLoading : false;
+
+ const [revealedKey, setRevealedKey] = useState(null);
+ const [depositOpen, setDepositOpen] = useState(false);
+
+ const handleProvision = async () => {
+ try {
+ await provision.mutateAsync();
+ } catch {
+ // useProvisionAgent shows its own error toast.
+ }
+ };
+
+ const handleGenerate = async () => {
+ try {
+ const result = await generateApiKey.mutateAsync(undefined);
+ setRevealedKey(result.key);
+ } catch {
+ Toast.show({
+ type: 'error',
+ text1: 'Failed to generate API key',
+ props: { badgeText: 'Error' },
+ });
+ }
+ };
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ ) : !isProvisioned ? (
+
+
+
+ Set up your Agent Wallet
+
+
+ We'll create a new EOA under your existing Turnkey wallet that can sign x402
+ payments on Base. Start earning yield on idle USDC in your agent wallet.
+
+
+
+ ) : (
+ <>
+ setDepositOpen(true)}
+ onGenerateApiKey={handleGenerate}
+ onCopyPrompt={handleCopyPrompt}
+ />
+
+
+
+
+
+
+ revokeApiKey.mutate(id)}
+ revokingId={
+ revokeApiKey.isPending ? (revokeApiKey.variables as string) : undefined
+ }
+ />
+
+
+
+
+ How to use
+
+
+ >
+ )}
+
+ setRevealedKey(null)}
+ apiKey={revealedKey}
+ />
+ setDepositOpen(false)}
+ agentEoaAddress={agentEoaAddress}
+ />
+
+
+ );
+}
+
+interface ProvisionedHeaderProps {
+ isScreenMedium: boolean;
+ isGenerating: boolean;
+ onDeposit: () => void;
+ onGenerateApiKey: () => void;
+ onCopyPrompt: () => void;
+}
+
+function ProvisionedHeader({
+ isScreenMedium,
+ isGenerating,
+ onDeposit,
+ onGenerateApiKey,
+ onCopyPrompt,
+}: ProvisionedHeaderProps) {
+ if (isScreenMedium) {
+ return (
+
+
+ Agent Wallet
+ Your Solid Wallet is now Agentic
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ Agent Wallet
+ Your Solid Wallet is now Agentic
+
+
+ } label="Deposit" onPress={onDeposit} />
+
+ ) : (
+
+ )
+ }
+ label="API key"
+ onPress={onGenerateApiKey}
+ variant="dark"
+ disabled={isGenerating}
+ />
+ }
+ label="Prompt"
+ onPress={onCopyPrompt}
+ variant="dark"
+ />
+
+
+ );
+}
+
+interface CircleActionProps {
+ icon: React.ReactNode;
+ label: string;
+ onPress: () => void;
+ variant?: 'brand' | 'dark';
+ disabled?: boolean;
+}
+
+function CircleAction({ icon, label, onPress, variant = 'brand', disabled }: CircleActionProps) {
+ return (
+
+
+ {icon}
+
+ {label}
+
+ );
+}
+
+interface BalanceCardProps {
+ balance?: bigint;
+ balanceLoading: boolean;
+}
+
+/**
+ * Mirrors /card/details `SpendingBalanceCard` shape — rounded-[20px] base
+ * with a LinearGradient overlay, big balance up top, secondary stat under
+ * it. Blue palette so the agent page reads distinctly from the green card
+ * page, purple savings, and yellow rewards.
+ */
+function BalanceCard({ balance, balanceLoading }: BalanceCardProps) {
+ const formatted = balanceLoading ? null : formatUsdc(balance);
+ return (
+
+
+
+
+ Spendable balance
+ {balanceLoading ? (
+
+ ) : (
+ {formatted}
+ )}
+
+
+ Earning
+ Yield on idle USDC
+
+
+
+ );
+}
+
+interface ApiKeysCardProps {
+ address?: string;
+ apiKeys: Parameters[0]['apiKeys'];
+ isLoading: boolean;
+ onRevoke: (id: string) => void;
+ revokingId?: string;
+}
+
+function ApiKeysCard({ address, apiKeys, isLoading, onRevoke, revokingId }: ApiKeysCardProps) {
+ return (
+
+
+ API Keys
+
+ Authenticate AI tools that pay through your agent wallet.
+
+
+ {address ? (
+
+ Agent wallet address
+
+
+ {eclipseAddress(address, 8, 6)}
+
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/components/Agent/AgentDepositBorrowForm.tsx b/components/Agent/AgentDepositBorrowForm.tsx
new file mode 100644
index 00000000..c1c9bd38
--- /dev/null
+++ b/components/Agent/AgentDepositBorrowForm.tsx
@@ -0,0 +1,154 @@
+import { useMemo, useState } from 'react';
+import { ActivityIndicator, Linking, View } from 'react-native';
+import Toast from 'react-native-toast-message';
+
+import { BorrowSlider } from '@/components/Card/BorrowSlider';
+import TokenDetails from '@/components/TokenCard/TokenDetails';
+import { Button } from '@/components/ui/button';
+import Skeleton from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { useAaveBorrowPosition } from '@/hooks/useAaveBorrowPosition';
+import useBorrowAndDepositToAgent from '@/hooks/useBorrowAndDepositToAgent';
+import { isProduction } from '@/lib/config';
+import { Status } from '@/lib/types';
+import { formatNumber } from '@/lib/utils';
+
+const SO_USD_LTV = 70n;
+
+type Props = {
+ agentEoaAddress?: string;
+ onSuccess: () => void;
+};
+
+const AgentDepositBorrowForm = ({ agentEoaAddress, onSuccess }: Props) => {
+ const [sliderValue, setSliderValue] = useState(0);
+ const {
+ totalSupplied,
+ totalBorrowed,
+ borrowAPY,
+ isLoading: positionLoading,
+ } = useAaveBorrowPosition();
+ const { borrowAndDeposit, bridgeStatus } = useBorrowAndDepositToAgent(agentEoaAddress);
+
+ const maxBorrowAmount = useMemo(
+ () => Math.max(0, totalSupplied * 0.69 - totalBorrowed),
+ [totalSupplied, totalBorrowed],
+ );
+
+ const collateralRequired = useMemo(() => {
+ if (sliderValue <= 0) return 0;
+ return (sliderValue * 100) / Number(SO_USD_LTV);
+ }, [sliderValue]);
+
+ const submitting = bridgeStatus === Status.PENDING;
+ const amountValid = sliderValue > 0 && sliderValue <= maxBorrowAmount;
+
+ const handleSubmit = async () => {
+ if (!amountValid || !agentEoaAddress) return;
+ try {
+ await borrowAndDeposit(sliderValue.toString());
+ Toast.show({
+ type: 'success',
+ text1: 'Deposit submitted',
+ text2:
+ 'Borrowed against soUSD on Fuse and sent via Stargate. Funds arrive on Base in ~1–5 min.',
+ props: { badgeText: 'Success' },
+ });
+ onSuccess();
+ } catch (err) {
+ Toast.show({
+ type: 'error',
+ text1: 'Deposit failed',
+ text2: err instanceof Error ? err.message : 'Unknown error',
+ props: { badgeText: 'Error' },
+ });
+ }
+ };
+
+ return (
+
+ {!isProduction && (
+
+
+ Borrow contract available only in production
+
+
+ soUSD and the Aave borrow market aren't deployed in this environment. The borrow
+ flow will fail — use the external-wallet option to fund the agent on Base for testing.
+
+
+ )}
+
+
+
+
+
+
+
+ Borrow rate
+
+ {positionLoading ? (
+
+ ) : (
+
+ {formatNumber(borrowAPY, 2)}%
+
+ )}
+
+
+
+
+
+ Collateral Required
+
+
+
+ {!sliderValue ? (
+
+ ) : (
+
+ {formatNumber(collateralRequired)} soUSD
+
+ )}
+
+
+
+
+ Use your soUSD as collateral to borrow USDC and fund your agent wallet on Base while
+ earning yield.{' '}
+ {
+ Linking.openURL(
+ 'https://support.solid.xyz/en/articles/13545322-borrow-against-your-savings',
+ );
+ }}
+ className="text-base font-medium leading-5 text-[#94F27F] web:hover:opacity-70"
+ >
+ Learn more.
+
+
+
+
+
+
+
+ );
+};
+
+export default AgentDepositBorrowForm;
diff --git a/components/Agent/AgentDepositExternalForm.tsx b/components/Agent/AgentDepositExternalForm.tsx
new file mode 100644
index 00000000..cc93ddee
--- /dev/null
+++ b/components/Agent/AgentDepositExternalForm.tsx
@@ -0,0 +1,54 @@
+import { View } from 'react-native';
+import { Image } from 'expo-image';
+import { Info } from 'lucide-react-native';
+
+import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress';
+import { Text } from '@/components/ui/text';
+
+const baseIcon = require('@/assets/images/base.png');
+
+type Props = {
+ agentEoaAddress: string;
+};
+
+const AgentDepositExternalForm = ({ agentEoaAddress }: Props) => {
+ return (
+
+
+
+
+ Base
+
+
+ Send only USDC on Base to this address. Other tokens or chains may result in permanent
+ loss of funds.
+
+ >
+ }
+ />
+
+
+
+
+ Funds sent here arrive at your agent wallet immediately and do not earn yield. To keep
+ earning yield on the principal, use the Borrow against savings option instead.
+
+
+
+ );
+};
+
+export default AgentDepositExternalForm;
diff --git a/components/Agent/AgentDepositModal.tsx b/components/Agent/AgentDepositModal.tsx
new file mode 100644
index 00000000..45b08b96
--- /dev/null
+++ b/components/Agent/AgentDepositModal.tsx
@@ -0,0 +1,190 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Platform, Pressable, View } from 'react-native';
+import { ChevronDown, Leaf, Wallet as WalletIcon } from 'lucide-react-native';
+
+import AgentDepositBorrowForm from '@/components/Agent/AgentDepositBorrowForm';
+import AgentDepositExternalForm from '@/components/Agent/AgentDepositExternalForm';
+import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Text } from '@/components/ui/text';
+
+const MODAL_OPEN: ModalState = { name: 'agent-deposit', number: 1 };
+const CLOSE_STATE: ModalState = { name: 'close', number: 0 };
+
+type AgentDepositSource = 'borrow' | 'external';
+
+type Props = {
+ open: boolean;
+ onClose: () => void;
+ agentEoaAddress?: string;
+};
+
+const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => {
+ const [source, setSource] = useState('borrow');
+
+ useEffect(() => {
+ if (!open) setSource('borrow');
+ }, [open]);
+
+ return (
+ {
+ if (!value) onClose();
+ }}
+ trigger={null}
+ title="Deposit to Agent Wallet"
+ contentKey="agent-deposit"
+ containerClassName="min-h-[42rem] overflow-y-auto flex-1"
+ >
+
+
+ From
+
+
+
+ {source === 'external' && agentEoaAddress ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+const SOURCE_LABEL: Record = {
+ borrow: 'Borrow against Savings',
+ external: 'External Wallet',
+};
+
+const SOURCE_TOKEN: Record = {
+ borrow: '',
+ external: 'USDC',
+};
+
+const SourceIcon = ({ value }: { value: AgentDepositSource }) =>
+ value === 'borrow' ? (
+
+ ) : (
+
+ );
+
+const SourceSelector = ({
+ value,
+ onChange,
+}: {
+ value: AgentDepositSource;
+ onChange: (next: AgentDepositSource) => void;
+}) =>
+ Platform.OS === 'web' ? (
+
+ ) : (
+
+ );
+
+const SourceSelectorWeb = ({
+ value,
+ onChange,
+}: {
+ value: AgentDepositSource;
+ onChange: (next: AgentDepositSource) => void;
+}) => (
+
+
+
+
+
+ {SOURCE_LABEL[value]}
+
+
+ {SOURCE_TOKEN[value] ? (
+ {SOURCE_TOKEN[value]}
+ ) : null}
+
+
+
+
+
+ onChange('borrow')}
+ className="flex-row items-center gap-2 px-4 py-3 web:cursor-pointer"
+ >
+
+ Borrow against Savings
+
+ onChange('external')}
+ className="flex-row items-center gap-2 px-4 py-3 web:cursor-pointer"
+ >
+
+ External Wallet
+
+
+
+);
+
+const SourceSelectorNative = ({
+ value,
+ onChange,
+}: {
+ value: AgentDepositSource;
+ onChange: (next: AgentDepositSource) => void;
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const select = useCallback(
+ (next: AgentDepositSource) => {
+ onChange(next);
+ setIsOpen(false);
+ },
+ [onChange],
+ );
+
+ return (
+
+ setIsOpen(open => !open)}
+ >
+
+
+ {SOURCE_LABEL[value]}
+
+
+ {SOURCE_TOKEN[value] ? (
+ {SOURCE_TOKEN[value]}
+ ) : null}
+
+
+
+ {isOpen && (
+
+ select('borrow')}
+ >
+
+ Borrow against Savings
+
+ select('external')}
+ >
+
+ External Wallet
+
+
+ )}
+
+ );
+};
+
+export default AgentDepositModal;
diff --git a/components/Agent/ApiKeyList.tsx b/components/Agent/ApiKeyList.tsx
new file mode 100644
index 00000000..04f19ac4
--- /dev/null
+++ b/components/Agent/ApiKeyList.tsx
@@ -0,0 +1,65 @@
+import { ActivityIndicator, View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { AgentApiKeySummary } from '@/lib/types';
+
+type Props = {
+ apiKeys: AgentApiKeySummary[] | undefined;
+ isLoading: boolean;
+ onRevoke: (id: string) => void;
+ revokingId?: string;
+};
+
+const formatDate = (iso?: string) => {
+ if (!iso) return '—';
+ return new Date(iso).toLocaleDateString();
+};
+
+const ApiKeyList = ({ apiKeys, isLoading, onRevoke, revokingId }: Props) => {
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const active = (apiKeys ?? []).filter(k => !k.revokedAt);
+ if (active.length === 0) {
+ return (
+
+ No API keys yet. Generate one to start integrating with your AI tool.
+
+ );
+ }
+
+ return (
+
+ {active.map(k => (
+
+
+ sk_solid_live_•••••{k.prefix}
+
+ {k.name ? `${k.name} · ` : ''}created {formatDate(k.createdAt)}
+ {k.lastUsedAt ? ` · last used ${formatDate(k.lastUsedAt)}` : ''}
+
+
+
+
+ ))}
+
+ );
+};
+
+export default ApiKeyList;
diff --git a/components/Agent/ApiKeyRevealModal.tsx b/components/Agent/ApiKeyRevealModal.tsx
new file mode 100644
index 00000000..78b868c4
--- /dev/null
+++ b/components/Agent/ApiKeyRevealModal.tsx
@@ -0,0 +1,50 @@
+import { View } from 'react-native';
+
+import CopyToClipboard from '@/components/CopyToClipboard';
+import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+
+type Props = {
+ open: boolean;
+ onClose: () => void;
+ apiKey: string | null;
+};
+
+const MODAL_STATE: ModalState = { name: 'agent-api-key-reveal', number: 1 };
+const CLOSE_STATE: ModalState = { name: 'close', number: 0 };
+
+const ApiKeyRevealModal = ({ open, onClose, apiKey }: Props) => {
+ return (
+ !isOpen && onClose()}
+ trigger={null}
+ title="Your new API key"
+ contentKey="agent-api-key-reveal"
+ shouldAnimate={false}
+ >
+
+
+ This is the only time you'll see the full key. Copy it now and store it securely. If
+ you lose it, generate a new one.
+
+ {apiKey ? (
+
+
+ {apiKey}
+
+
+
+ ) : null}
+
+
+
+ );
+};
+
+export default ApiKeyRevealModal;
diff --git a/components/Agent/IntegrationSnippet.tsx b/components/Agent/IntegrationSnippet.tsx
new file mode 100644
index 00000000..6b0426d7
--- /dev/null
+++ b/components/Agent/IntegrationSnippet.tsx
@@ -0,0 +1,28 @@
+import { View } from 'react-native';
+
+import CopyToClipboard from '@/components/CopyToClipboard';
+import { Text } from '@/components/ui/text';
+import { buildAgentIntegrationCurl } from '@/constants/agentPromptTemplate';
+import { EXPO_PUBLIC_FLASH_API_BASE_URL } from '@/lib/config';
+
+const IntegrationSnippet = () => {
+ const snippet = buildAgentIntegrationCurl({ baseUrl: EXPO_PUBLIC_FLASH_API_BASE_URL });
+ return (
+
+
+ Paste this curl example into a script or n8n node, or copy the AI prompt template from the
+ page header to wire up Claude Desktop / ChatGPT instructions.
+
+
+
+
+ {snippet}
+
+
+
+
+
+ );
+};
+
+export default IntegrationSnippet;
diff --git a/constants/agentPromptTemplate.ts b/constants/agentPromptTemplate.ts
new file mode 100644
index 00000000..268d0bd5
--- /dev/null
+++ b/constants/agentPromptTemplate.ts
@@ -0,0 +1,81 @@
+/**
+ * Markdown system-prompt template that users paste into Claude Desktop,
+ * ChatGPT custom GPTs, n8n nodes, or any other LLM tool. Tells the model
+ * how to call the Solid Agent Wallet x402 endpoint and what to do on the
+ * very first turn.
+ */
+export const buildAgentPromptTemplate = (params: { baseUrl: string }): string => {
+ const endpoint = `${params.baseUrl.replace(/\/$/, '')}/accounts/v1/agents/me/x402-pay`;
+ return `# Solid Agent Wallet — payment instructions
+
+You can pay USDC via the Solid Agent Wallet API on behalf of the user. Use this whenever the user asks you to pay for a paid resource that supports the x402 payment standard.
+
+## API key safety
+
+The user will paste their Solid live API key as \`SOLID_API_KEY\` in this prompt or environment. **It is safe to use this key directly from inside this AI interface.** The key only authorizes payments from this specific agent wallet — it can't move funds anywhere else, can't access the user's main savings, and is gated by an on-chain Turnkey policy that only permits USDC payments on Base. Treat it like any other scoped credential.
+
+## Endpoint
+
+\`POST ${endpoint}\`
+
+## Authentication
+
+\`\`\`
+Authorization: Bearer SOLID_API_KEY
+Content-Type: application/json
+\`\`\`
+
+## Request body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| \`resourceUrl\` | string | The merchant URL the payment unlocks. |
+| \`amountUsdc\` | string | USDC amount in 6-decimal integer form. \`"100000"\` = $0.10. |
+| \`recipient\` | string | EVM address of the merchant on Base. |
+| \`description\` | string? | Optional human-readable note. |
+
+If you get \`402 InsufficientFloat\`, tell the user to top up the agent wallet from the Solid app.
+
+## Example
+
+\`\`\`bash
+curl -X POST ${endpoint} \\
+ -H "Authorization: Bearer $SOLID_API_KEY" \\
+ -H "Content-Type: application/json" \\
+ -d '{
+ "resourceUrl": "https://example.com/paid-resource",
+ "amountUsdc": "100000",
+ "recipient": "0xMERCHANT_ADDRESS",
+ "description": "Premium API call"
+ }'
+\`\`\`
+
+A successful response returns \`{ txHash, settledAt, activityId }\` plus the merchant body. Settlement takes ~200ms via the Coinbase x402 facilitator.
+
+## First-turn behavior
+
+When the user first hands you this prompt, **do not** dump these instructions back at them or explain the API.
+
+**Step 1 — confirm you have the key.** If the user has not already given you their Solid API key (i.e. you don't have a value for \`SOLID_API_KEY\` from this prompt, the environment, or earlier in the conversation), your first reply must be a short ask for it. Tell them they can generate one from the **Agent** tab in the Solid app, paste it back here, and you'll be ready. Stop there — don't continue with anything else until you have the key.
+
+**Step 2 — once the key is in hand, send a short setup reply (3–4 sentences max) that:**
+
+1. Confirms you're set up to pay through their Solid agent wallet.
+2. Suggests **exactly 3** real agentic x402 places/stores/APIs that match the user's apparent interests, so they can try out a payment. Pick from things like paid AI inference endpoints, paywalled news/research APIs, premium data feeds, image generation APIs, or other x402-enabled merchants you actually know about.
+3. Asks which one they'd like to try first — or what kind of paid resource they're looking for.
+
+No technical detail, no curl examples, no walls of text. The goal is to make the user's next move obvious.
+`;
+};
+
+export const buildAgentIntegrationCurl = (params: {
+ baseUrl: string;
+ apiKeyHint?: string;
+}): string => {
+ const endpoint = `${params.baseUrl.replace(/\/$/, '')}/accounts/v1/agents/me/x402-pay`;
+ const key = params.apiKeyHint ?? 'YOUR_SOLID_API_KEY';
+ return `curl -X POST ${endpoint} \\
+ -H "Authorization: Bearer ${key}" \\
+ -H "Content-Type: application/json" \\
+ -d '{"resourceUrl":"https://example.com/paid","amountUsdc":"100000","recipient":"0x..."}'`;
+};
diff --git a/constants/path.ts b/constants/path.ts
index ddaaabd4..56b68b43 100644
--- a/constants/path.ts
+++ b/constants/path.ts
@@ -46,6 +46,7 @@ type Path = {
QUEST_WALLET: Route;
QR_SCANNER: Route;
RESCUE_TOKEN: Href;
+ AGENT: Href;
};
export const path: Path = {
@@ -96,4 +97,5 @@ export const path: Path = {
// Note: Type assertion needed because Expo Router types are regenerated at dev server start
QR_SCANNER: '/qr-scanner' as Route,
RESCUE_TOKEN: '/rescue-token' as Href,
+ AGENT: '/agent' as Href,
};
diff --git a/constants/transaction.ts b/constants/transaction.ts
index ff08b20b..f072410d 100644
--- a/constants/transaction.ts
+++ b/constants/transaction.ts
@@ -110,4 +110,12 @@ export const TRANSACTION_DETAILS: Record =
sign: TransactionDirection.IN,
category: TransactionCategory.WALLET_TRANSFER,
},
+ [TransactionType.AGENT_X402_PAYMENT]: {
+ sign: TransactionDirection.OUT,
+ category: TransactionCategory.WALLET_TRANSFER,
+ },
+ [TransactionType.AGENT_WALLET_DEPOSIT]: {
+ sign: TransactionDirection.OUT,
+ category: TransactionCategory.WALLET_TRANSFER,
+ },
};
diff --git a/hooks/useAgent.ts b/hooks/useAgent.ts
new file mode 100644
index 00000000..24664e78
--- /dev/null
+++ b/hooks/useAgent.ts
@@ -0,0 +1,258 @@
+import Toast from 'react-native-toast-message';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { StamperType, useTurnkey } from '@turnkey/react-native-wallet-kit';
+import { Address, erc20Abi } from 'viem';
+import { base } from 'viem/chains';
+
+import {
+ fetchAgent,
+ fetchAgentApiKeys,
+ fetchAgentHasDeposited,
+ generateAgentApiKey,
+ provisionAgentInit,
+ provisionAgentPolicy,
+ provisionAgentUser,
+ provisionAgentWalletAccount,
+ revokeAgentApiKey,
+} from '@/lib/api';
+import {
+ AgentApiKeySummary,
+ AgentSummary,
+ GenerateAgentApiKeyResponse,
+ SignedTurnkeyRequest,
+} from '@/lib/types';
+import { withRefreshToken } from '@/lib/utils';
+import { getStargateToken } from '@/lib/utils/stargate';
+import { publicClient } from '@/lib/wagmi';
+
+const AGENT_QUERY_KEY = ['agent'] as const;
+const AGENT_API_KEYS_QUERY_KEY = ['agent', 'api-keys'] as const;
+const AGENT_BALANCE_QUERY_KEY = (address?: string) =>
+ ['agent', 'balance', address?.toLowerCase()] as const;
+const AGENT_DEPOSITED_QUERY_KEY = ['agent', 'has-deposited'] as const;
+
+// Reuse the canonical Base USDC mapping the Stargate bridge already
+// maintains — keeps both feature surfaces in sync if it ever changes.
+const BASE_USDC_ADDRESS = getStargateToken(base.id) as Address | null;
+
+export const useAgentQuery = () =>
+ useQuery({
+ queryKey: AGENT_QUERY_KEY,
+ queryFn: () => withRefreshToken(() => fetchAgent()),
+ staleTime: 60 * 1000,
+ });
+
+/**
+ * On-chain USDC balance for the agent EOA on Base. 6-decimal raw bigint.
+ * Mirrors the polling cadence and resilience options of useBalances for the
+ * Safe wallet (hooks/useBalances.ts).
+ */
+export const useAgentBalance = (agentEoaAddress?: string) =>
+ useQuery({
+ queryKey: AGENT_BALANCE_QUERY_KEY(agentEoaAddress),
+ enabled: !!agentEoaAddress && !!BASE_USDC_ADDRESS,
+ queryFn: async () => {
+ const client = publicClient(base.id);
+ return client.readContract({
+ address: BASE_USDC_ADDRESS as Address,
+ abi: erc20Abi,
+ functionName: 'balanceOf',
+ args: [agentEoaAddress as Address],
+ });
+ },
+ staleTime: 5_000,
+ gcTime: 5 * 60 * 1000,
+ retry: 3,
+ retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30000),
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ refetchInterval: 5_000,
+ refetchIntervalInBackground: false,
+ });
+
+/**
+ * Whether the agent has ever received a successful deposit. Derived from
+ * the activity feed and cached for an hour — a one-way transition, so we
+ * can be aggressive about staleness.
+ */
+export const useAgentDeposited = (enabled: boolean) =>
+ useQuery({
+ queryKey: AGENT_DEPOSITED_QUERY_KEY,
+ enabled,
+ queryFn: () => withRefreshToken(() => fetchAgentHasDeposited()),
+ staleTime: 60 * 60 * 1000,
+ gcTime: 24 * 60 * 60 * 1000,
+ });
+
+/**
+ * Drives the four-step session-stamped provisioning flow:
+ * init → walletAccount → user → policy
+ *
+ * Each step's body is built by the backend; the user's Turnkey session API
+ * key signs them via `httpClient.stampX(body, StamperType.ApiKey)`. We mint
+ * (or refresh) the session up front with one passkey gesture, then every
+ * subsequent stamp is silent.
+ */
+export const useProvisionAgent = () => {
+ const queryClient = useQueryClient();
+ const { httpClient, loginWithPasskey, refreshSession, getSession } = useTurnkey();
+
+ return useMutation({
+ mutationFn: async () => {
+ // 1. Backend mints the provisioning record + first activity body. If
+ // a prior attempt already derived the wallet account, the response
+ // carries an agentEoaAddress and `activity` is the createUsers
+ // body — we skip step 2 in that case.
+ const initResult = await withRefreshToken(() => provisionAgentInit());
+ const { provisioningId, subOrganizationId } = initResult;
+ const skipWalletAccount = !!initResult.agentEoaAddress;
+
+ // 2. Establish a Turnkey read-write session against the user's
+ // sub-org — one passkey gesture if we don't already have a live
+ // session with enough headroom. Sessions minted against the
+ // parent org can't sign sub-org activities (PUBLIC_KEY_NOT_FOUND).
+ await ensureSession({
+ getSession,
+ refreshSession,
+ loginWithPasskey,
+ organizationId: subOrganizationId,
+ });
+
+ if (!httpClient) {
+ throw new Error('Turnkey httpClient is not initialized');
+ }
+
+ // 3. Stamp + relay createWalletAccounts unless init already adopted
+ // an existing path. `nextActivity` carries whatever step we owe
+ // next: createWalletAccounts (normal) or createUsers (skipped).
+ let nextActivity = initResult.activity;
+ if (!skipWalletAccount) {
+ const signed1 = await httpClient.stampCreateWalletAccounts(
+ nextActivity.body as Parameters[0],
+ StamperType.ApiKey,
+ );
+ if (!signed1) throw new Error('Failed to stamp createWalletAccounts');
+ const { activity } = await provisionAgentWalletAccount({
+ provisioningId,
+ signed: signed1 as SignedTurnkeyRequest,
+ });
+ nextActivity = activity;
+ }
+
+ // 4. Stamp + relay createUsers.
+ const signed2 = await httpClient.stampCreateUsers(
+ nextActivity.body as Parameters[0],
+ StamperType.ApiKey,
+ );
+ if (!signed2) throw new Error('Failed to stamp createUsers');
+ const { activity: policyActivity } = await provisionAgentUser({
+ provisioningId,
+ signed: signed2 as SignedTurnkeyRequest,
+ });
+
+ // 5. Stamp + relay createPolicy. Backend has now baked
+ // agentTurnkeyUserId into the CEL.
+ const signed3 = await httpClient.stampCreatePolicy(
+ policyActivity.body as Parameters[0],
+ StamperType.ApiKey,
+ );
+ if (!signed3) throw new Error('Failed to stamp createPolicy');
+ return provisionAgentPolicy({
+ provisioningId,
+ signed: signed3 as SignedTurnkeyRequest,
+ });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: AGENT_QUERY_KEY });
+ queryClient.invalidateQueries({ queryKey: ['user'] });
+ Toast.show({
+ type: 'success',
+ text1: 'Agent provisioned',
+ text2: 'Deposit USD to start using your agent',
+ props: { badgeText: 'Success' },
+ });
+ },
+ onError: (err: unknown) => {
+ const message =
+ err && typeof err === 'object' && 'message' in err && typeof err.message === 'string'
+ ? err.message
+ : undefined;
+ Toast.show({
+ type: 'error',
+ text1: 'Failed to provision agent',
+ text2: message?.toLowerCase().includes('cancel')
+ ? 'Passkey prompt was cancelled'
+ : undefined,
+ props: { badgeText: 'Error' },
+ });
+ },
+ });
+};
+
+const SESSION_HEADROOM_SECONDS = 60;
+
+const ensureSession = async (deps: {
+ getSession: ReturnType['getSession'];
+ refreshSession: ReturnType['refreshSession'];
+ loginWithPasskey: ReturnType['loginWithPasskey'];
+ organizationId: string;
+}) => {
+ const existing = await deps.getSession();
+ const nowSeconds = Date.now() / 1000;
+ // Reuse if the live session is for the right org and has enough headroom.
+ if (
+ existing &&
+ existing.organizationId === deps.organizationId &&
+ existing.expiry > nowSeconds + SESSION_HEADROOM_SECONDS
+ ) {
+ try {
+ await deps.refreshSession({ expirationSeconds: '900' });
+ return;
+ } catch {
+ // Fall through to a fresh passkey login.
+ }
+ }
+ await deps.loginWithPasskey({
+ expirationSeconds: '900',
+ organizationId: deps.organizationId,
+ });
+};
+
+export const useAgentApiKeys = () =>
+ useQuery({
+ queryKey: AGENT_API_KEYS_QUERY_KEY,
+ queryFn: () => withRefreshToken(() => fetchAgentApiKeys()),
+ staleTime: 30 * 1000,
+ });
+
+export const useGenerateAgentApiKey = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (name?: string) => withRefreshToken(() => generateAgentApiKey(name)),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: AGENT_API_KEYS_QUERY_KEY });
+ },
+ });
+};
+
+export const useRevokeAgentApiKey = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => withRefreshToken(() => revokeAgentApiKey(id)),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: AGENT_API_KEYS_QUERY_KEY });
+ Toast.show({
+ type: 'success',
+ text1: 'API key revoked',
+ props: { badgeText: 'Success' },
+ });
+ },
+ onError: () => {
+ Toast.show({
+ type: 'error',
+ text1: 'Failed to revoke API key',
+ props: { badgeText: 'Error' },
+ });
+ },
+ });
+};
diff --git a/hooks/useBorrowAndDepositToAgent.ts b/hooks/useBorrowAndDepositToAgent.ts
new file mode 100644
index 00000000..86a335ea
--- /dev/null
+++ b/hooks/useBorrowAndDepositToAgent.ts
@@ -0,0 +1,97 @@
+import { useCallback, useState } from 'react';
+import * as Sentry from '@sentry/react-native';
+import { Address } from 'abitype';
+import { TransactionReceipt } from 'viem';
+import { base } from 'viem/chains';
+
+import { useActivityActions } from '@/hooks/useActivityActions';
+import { USER_CANCELLED_TRANSACTION } from '@/lib/execute';
+import { Status, TransactionType } from '@/lib/types';
+import { executeBorrowAndBridge } from '@/lib/utils/borrowAndBridge';
+import { getStargateToken } from '@/lib/utils/stargate';
+
+import useUser from './useUser';
+
+type AgentDepositResult = {
+ borrowAndDeposit: (amount: string) => Promise;
+ bridgeStatus: Status;
+ error: string | null;
+};
+
+const useBorrowAndDepositToAgent = (agentEoaAddress?: string): AgentDepositResult => {
+ const { user, safeAA } = useUser();
+ const { trackTransaction } = useActivityActions();
+ const [bridgeStatus, setBridgeStatus] = useState(Status.IDLE);
+ const [error, setError] = useState(null);
+
+ const borrowAndDeposit = useCallback(
+ async (amountToBorrow: string) => {
+ try {
+ if (!user) throw new Error('User is not selected');
+ if (!agentEoaAddress) throw new Error('Agent wallet not provisioned');
+ const baseUsdc = getStargateToken(base.id);
+ if (!baseUsdc) throw new Error('Base USDC not configured for Stargate');
+
+ setBridgeStatus(Status.PENDING);
+ setError(null);
+
+ const transactionResult = await executeBorrowAndBridge({
+ user: {
+ safeAddress: user.safeAddress,
+ suborgId: user.suborgId,
+ signWith: user.signWith,
+ userId: user.userId,
+ },
+ destinationAddress: agentEoaAddress as Address,
+ destinationChainId: base.id,
+ destinationChainKey: 'base',
+ destinationToken: baseUsdc as Address,
+ amountToBorrow,
+ safeAA,
+ trackTransaction,
+ activityType: TransactionType.AGENT_WALLET_DEPOSIT,
+ activityTitle: 'Deposit to Agent Wallet',
+ flowTag: 'borrow_and_deposit_to_agent',
+ });
+
+ if (transactionResult === USER_CANCELLED_TRANSACTION) {
+ throw new Error('User cancelled transaction');
+ }
+
+ Sentry.addBreadcrumb({
+ message: 'Borrow + deposit to Agent successful',
+ category: 'bridge',
+ data: {
+ amount: amountToBorrow,
+ transactionHash: transactionResult.transactionHash,
+ userAddress: user.safeAddress,
+ destinationAddress: agentEoaAddress,
+ destinationChainId: base.id,
+ },
+ });
+
+ setBridgeStatus(Status.SUCCESS);
+ return transactionResult;
+ } catch (err) {
+ console.error(err);
+ Sentry.captureException(err, {
+ tags: { operation: 'borrow_and_deposit_to_agent' },
+ extra: {
+ amount: amountToBorrow,
+ userAddress: user?.safeAddress,
+ agentEoaAddress,
+ },
+ user: user ? { id: user.userId, address: user.safeAddress } : undefined,
+ });
+ setBridgeStatus(Status.ERROR);
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ throw err;
+ }
+ },
+ [user, agentEoaAddress, safeAA, trackTransaction],
+ );
+
+ return { borrowAndDeposit, bridgeStatus, error };
+};
+
+export default useBorrowAndDepositToAgent;
diff --git a/hooks/useBorrowAndDepositToCard.ts b/hooks/useBorrowAndDepositToCard.ts
index f1204674..0fbfffa5 100644
--- a/hooks/useBorrowAndDepositToCard.ts
+++ b/hooks/useBorrowAndDepositToCard.ts
@@ -1,59 +1,32 @@
import { useCallback, useState } from 'react';
import * as Sentry from '@sentry/react-native';
import { Address } from 'abitype';
-import { erc20Abi, pad, TransactionReceipt } from 'viem';
-import { readContract } from 'viem/actions';
-import { fuse, mainnet } from 'viem/chains';
-import { encodeFunctionData, parseUnits } from 'viem/utils';
+import { TransactionReceipt } from 'viem';
+import { fuse } from 'viem/chains';
-import { USDC_STARGATE } from '@/constants/addresses';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
import { useActivityActions } from '@/hooks/useActivityActions';
-import { AaveV3Pool_ABI } from '@/lib/abis/AaveV3Pool';
-import BridgePayamster_ABI from '@/lib/abis/BridgePayamster';
-import { CardDepositManager_ABI } from '@/lib/abis/CardDepositManager';
import { track } from '@/lib/analytics';
import {
- ADDRESSES,
EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID,
EXPO_PUBLIC_CARD_FUNDING_CHAIN_KEY,
} from '@/lib/config';
-import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute';
-import { StargateQuoteParams, Status, TransactionType } from '@/lib/types';
+import { USER_CANCELLED_TRANSACTION } from '@/lib/execute';
+import { Status, TransactionType } from '@/lib/types';
import { getCardDepositTokenAddress, getCardFundingAddress } from '@/lib/utils';
-import { getStargateChainId, getStargateQuote } from '@/lib/utils/stargate';
-import { publicClient } from '@/lib/wagmi';
+import { executeBorrowAndBridge } from '@/lib/utils/borrowAndBridge';
import { useCardContracts } from './useCardContracts';
import { useCardDetails } from './useCardDetails';
import { useCardProvider } from './useCardProvider';
import useUser from './useUser';
-// ABI for AccountantWithRateProviders getRate function
-const ACCOUNTANT_ABI = [
- {
- inputs: [],
- name: 'getRate',
- outputs: [
- {
- internalType: 'uint256',
- name: 'rate',
- type: 'uint256',
- },
- ],
- stateMutability: 'view',
- type: 'function',
- },
-] as const;
-
type BridgeResult = {
borrowAndDeposit: (amount: string) => Promise;
bridgeStatus: Status;
error: string | null;
};
-const soUSDLTV = 70n; // 80% LTV for soUSD (79% to avoid rounding errors)
-
const useBorrowAndDepositToCard = (): BridgeResult => {
const { user, safeAA } = useUser();
const { trackTransaction } = useActivityActions();
@@ -67,27 +40,19 @@ const useBorrowAndDepositToCard = (): BridgeResult => {
async (amountToBorrow: string) => {
try {
if (!user) {
- const error = new Error('User is not selected');
+ const err = new Error('User is not selected');
track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_ERROR, {
amount: amountToBorrow,
error: 'User not found',
step: 'validation',
source: 'useBridgeToCard',
});
- Sentry.captureException(error, {
- tags: {
- operation: 'bridge_to_card',
- step: 'validation',
- },
- extra: {
- amount: amountToBorrow,
- hasUser: !!user,
- },
+ Sentry.captureException(err, {
+ tags: { operation: 'bridge_to_card', step: 'validation' },
+ extra: { amount: amountToBorrow, hasUser: !!user },
});
- throw error;
+ throw err;
}
-
- // Get card's Arbitrum funding address (Rain: from contracts, Bridge: from card details)
if (!cardDetails) {
throw new Error('Card details not found');
}
@@ -97,26 +62,19 @@ const useBorrowAndDepositToCard = (): BridgeResult => {
provider,
contracts ?? undefined,
);
-
if (!arbitrumFundingAddress) {
- const error = new Error('Arbitrum funding address not found for card');
+ const err = new Error('Arbitrum funding address not found for card');
track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_ERROR, {
amount: amountToBorrow,
error: 'Arbitrum funding address not found',
step: 'validation',
source: 'useBridgeToCard',
});
- Sentry.captureException(error, {
- tags: {
- operation: 'bridge_to_card',
- step: 'validation',
- },
- extra: {
- amount: amountToBorrow,
- hasCardDetails: !!cardDetails,
- },
+ Sentry.captureException(err, {
+ tags: { operation: 'bridge_to_card', step: 'validation' },
+ extra: { amount: amountToBorrow, hasCardDetails: !!cardDetails },
});
- throw error;
+ throw err;
}
track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_INITIATED, {
@@ -129,193 +87,36 @@ const useBorrowAndDepositToCard = (): BridgeResult => {
setBridgeStatus(Status.PENDING);
setError(null);
- const rate = await readContract(publicClient(mainnet.id), {
- address: ADDRESSES.ethereum.accountant,
- abi: ACCOUNTANT_ABI,
- functionName: 'getRate',
- });
-
- const destinationAddress = arbitrumFundingAddress;
- const borrowAmountWei = parseUnits(amountToBorrow, 6);
- const supplyAmountWei = (borrowAmountWei * 100n * 1000000n) / (soUSDLTV * rate);
-
- const supplyApproveCalldata = encodeFunctionData({
- abi: erc20Abi,
- functionName: 'approve',
- args: [ADDRESSES.fuse.aaveV3Pool, supplyAmountWei],
- });
-
- const supplyCalldata = encodeFunctionData({
- abi: AaveV3Pool_ABI,
- functionName: 'supply',
- args: [ADDRESSES.fuse.vault, supplyAmountWei, user.safeAddress as Address, 0],
- });
-
- const borrowCalldata = encodeFunctionData({
- abi: AaveV3Pool_ABI,
- functionName: 'borrow',
- args: [USDC_STARGATE, borrowAmountWei, 2, 0, user.safeAddress as Address],
- });
-
- Sentry.addBreadcrumb({
- message: 'Starting bridge to Card transaction',
- category: 'bridge',
- data: {
- amount: amountToBorrow,
- amountWei: borrowAmountWei.toString(),
- userAddress: user.safeAddress,
- destinationAddress,
- chainId: fuse.id,
+ const transactionResult = await executeBorrowAndBridge({
+ user: {
+ safeAddress: user.safeAddress,
+ suborgId: user.suborgId,
+ signWith: user.signWith,
+ userId: user.userId,
},
+ destinationAddress: arbitrumFundingAddress as Address,
+ destinationChainId: EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID,
+ destinationChainKey: EXPO_PUBLIC_CARD_FUNDING_CHAIN_KEY,
+ destinationToken: getCardDepositTokenAddress(
+ EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID,
+ ) as Address,
+ amountToBorrow,
+ safeAA,
+ trackTransaction,
+ activityType: TransactionType.BORROW_AND_DEPOSIT_TO_CARD,
+ activityTitle: 'Borrow and deposit to Card',
+ flowTag: 'bridge_to_card',
});
- // Get Stargate quote for taxi route
- // Calculate minimum destination amount (95% of source amount for 5% slippage tolerance)
- const dstAmountMin = (borrowAmountWei * 95n) / 100n;
-
- const dstToken = getCardDepositTokenAddress(EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID);
- const quoteParams: StargateQuoteParams = {
- srcToken: USDC_STARGATE,
- srcChainKey: 'fuse',
- dstToken,
- dstChainKey: EXPO_PUBLIC_CARD_FUNDING_CHAIN_KEY,
- srcAddress: ADDRESSES.fuse.bridgePaymasterAddress,
- dstAddress: destinationAddress,
- srcAmount: borrowAmountWei.toString(),
- dstAmountMin: dstAmountMin.toString(),
- };
- const quote = await getStargateQuote(quoteParams);
- const taxiQuote = quote.quotes.find(q => q.route.includes('taxi'));
-
- if (!taxiQuote) {
- throw new Error('Taxi route not available from Stargate');
- }
-
- if (taxiQuote.error) {
- throw new Error(`Stargate quote error: ${taxiQuote.error}`);
- }
-
- // Get the transaction from the first step (should be the bridge step)
- const bridgeStep = taxiQuote.steps.find(step => step.type === 'bridge');
-
- if (!bridgeStep) {
- throw new Error('No bridge step found in Stargate quote');
- }
-
- const { transaction } = bridgeStep;
- const nativeFeeAmount = BigInt(transaction.value);
-
- const sendParam = {
- dstEid: getStargateChainId(EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID) as number,
- to: pad(destinationAddress as `0x${string}`, {
- size: 32,
- }),
- amountLD: borrowAmountWei,
- minAmountLD: dstAmountMin,
- extraOptions: '0x',
- composeMsg: '0x',
- oftCmd: '0x',
- };
-
- const calldata = encodeFunctionData({
- abi: CardDepositManager_ABI,
- functionName: 'depositUsingStargate',
- args: [
- transaction.to as Address,
- user.safeAddress as Address,
- sendParam,
- nativeFeeAmount,
- ADDRESSES.fuse.bridgePaymasterAddress,
- ],
- });
-
- const transactions = [
- {
- to: ADDRESSES.fuse.vault,
- data: supplyApproveCalldata,
- value: 0n,
- },
- {
- to: ADDRESSES.fuse.aaveV3Pool,
- data: supplyCalldata,
- value: 0n,
- },
- {
- to: ADDRESSES.fuse.aaveV3Pool,
- data: borrowCalldata,
- value: 0n,
- },
- // 1) Approve USDC.e from Safe to DepositManager
- {
- to: USDC_STARGATE,
- data: encodeFunctionData({
- abi: erc20Abi,
- functionName: 'approve',
- args: [ADDRESSES.fuse.cardDepositManager, borrowAmountWei],
- }),
- value: 0n,
- },
- // 2) Perform the Stargate taxi call via BridgePaymaster and DepositManager, forwarding the fee it now holds
- {
- to: ADDRESSES.fuse.bridgePaymasterAddress,
- data: encodeFunctionData({
- abi: BridgePayamster_ABI,
- functionName: 'callWithValue',
- args: [
- ADDRESSES.fuse.cardDepositManager,
- '0x37fe667d', // depositUsingStargate function selector
- calldata,
- nativeFeeAmount, // the native to forward
- ],
- }),
- value: 0n,
- },
- ];
-
- const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith);
-
- const result = await trackTransaction(
- {
- type: TransactionType.BORROW_AND_DEPOSIT_TO_CARD,
- title: `Borrow and deposit to Card`,
- shortTitle: `Borrow and deposit to Card`,
- amount: amountToBorrow,
- symbol: 'USDC.e', // Source symbol - bridging USDC.e
- chainId: fuse.id,
- fromAddress: user.safeAddress,
- toAddress: arbitrumFundingAddress,
- metadata: {
- description: `Borrow and deposit ${amountToBorrow} USDC from Fuse to Card on Arbitrum`,
- fee: transaction.value,
- sourceSymbol: 'USDC.e', // Track source symbol for display
- tokenAddress: USDC_STARGATE,
- },
- },
- onUserOpHash =>
- executeTransactions(
- smartAccountClient,
- transactions,
- 'Borrow and deposit to Card failed',
- fuse,
- onUserOpHash,
- ),
- );
-
- const transaction_result =
- result && typeof result === 'object' && 'transaction' in result
- ? result.transaction
- : result;
-
- if (transaction_result === USER_CANCELLED_TRANSACTION) {
- const error = new Error('User cancelled transaction');
+ if (transactionResult === USER_CANCELLED_TRANSACTION) {
+ const err = new Error('User cancelled transaction');
track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_CANCELLED, {
amount: amountToBorrow,
- fee: transaction.value,
from_chain: fuse.id,
to_chain: EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID,
source: 'useBridgeToCard',
});
- Sentry.captureException(error, {
+ Sentry.captureException(err, {
tags: {
operation: 'bridge_to_card',
step: 'execution',
@@ -324,22 +125,17 @@ const useBorrowAndDepositToCard = (): BridgeResult => {
extra: {
amount: amountToBorrow,
userAddress: user.safeAddress,
- destinationAddress,
+ destinationAddress: arbitrumFundingAddress,
chainId: fuse.id,
- fee: transaction.value,
- },
- user: {
- id: user?.userId,
- address: user?.safeAddress,
},
+ user: { id: user.userId, address: user.safeAddress },
});
- throw error;
+ throw err;
}
track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_COMPLETED, {
amount: amountToBorrow,
- transaction_hash: transaction_result.transactionHash,
- fee: transaction.value,
+ transaction_hash: transactionResult.transactionHash,
from_chain: fuse.id,
to_chain: EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID,
source: 'useBridgeToCard',
@@ -350,49 +146,40 @@ const useBorrowAndDepositToCard = (): BridgeResult => {
category: 'bridge',
data: {
amount: amountToBorrow,
- transactionHash: transaction_result.transactionHash,
+ transactionHash: transactionResult.transactionHash,
userAddress: user.safeAddress,
- destinationAddress,
+ destinationAddress: arbitrumFundingAddress,
chainId: fuse.id,
},
});
setBridgeStatus(Status.SUCCESS);
- return transaction_result;
- } catch (error) {
- console.error(error);
-
+ return transactionResult;
+ } catch (err) {
+ console.error(err);
track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_ERROR, {
amount: amountToBorrow,
from_chain: fuse.id,
to_chain: EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID,
- error: error instanceof Error ? error.message : 'Unknown error',
- user_cancelled: String(error).includes('cancelled'),
+ error: err instanceof Error ? err.message : 'Unknown error',
+ user_cancelled: String(err).includes('cancelled'),
step: 'execution',
source: 'useBridgeToCard',
});
-
- Sentry.captureException(error, {
- tags: {
- operation: 'bridge_to_card',
- step: 'execution',
- },
+ Sentry.captureException(err, {
+ tags: { operation: 'bridge_to_card', step: 'execution' },
extra: {
amount: amountToBorrow,
userAddress: user?.safeAddress,
chainId: fuse.id,
- errorMessage: error instanceof Error ? error.message : 'Unknown error',
+ errorMessage: err instanceof Error ? err.message : 'Unknown error',
bridgeStatus,
},
- user: {
- id: user?.suborgId,
- address: user?.safeAddress,
- },
+ user: { id: user?.suborgId, address: user?.safeAddress },
});
-
setBridgeStatus(Status.ERROR);
- setError(error instanceof Error ? error.message : 'Unknown error');
- throw error;
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ throw err;
}
},
[user, cardDetails, provider, contracts, safeAA, trackTransaction, bridgeStatus],
diff --git a/hooks/useNav.ts b/hooks/useNav.ts
index 7c1623fe..976e1c11 100644
--- a/hooks/useNav.ts
+++ b/hooks/useNav.ts
@@ -28,12 +28,25 @@ const card: MenuItem = {
href: path.CARD,
};
+const agent: MenuItem = {
+ label: 'Agent',
+ href: path.AGENT,
+};
+
const useNav = () => {
const points: MenuItem = {
label: isProduction ? 'Points' : 'Rewards',
href: isProduction ? path.POINTS : path.REWARDS,
};
- const menuItems: MenuItem[] = [home, savings, card, points, activity];
+ const menuItems: MenuItem[] = [
+ home,
+ savings,
+ card,
+ points,
+ // Agent wallet is not yet released to production — hide the tab there.
+ ...(isProduction ? [] : [agent]),
+ activity,
+ ];
return {
menuItems,
};
diff --git a/lib/api.ts b/lib/api.ts
index a06a284a..89717a82 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -24,6 +24,8 @@ import {
ActivityEvents,
AddressBookRequest,
AddressBookResponse,
+ AgentApiKeySummary,
+ AgentSummary,
APYsByAsset,
BridgeCustomerEndorsement,
BridgeCustomerResponse,
@@ -58,6 +60,7 @@ import {
ExtensionCardsResponse,
FromCurrency,
FullRewardsConfig,
+ GenerateAgentApiKeyResponse,
GetLifiQuoteParams,
HistoricalAPYPoint,
HoldingFundsPointsMultiplierConfig,
@@ -73,8 +76,11 @@ import {
MppCredentialsResponse,
Points,
PromotionsBannerResponse,
+ ProvisioningActivity,
+ ProvisioningInitResponse,
ProvisioningSessionRequest,
ProvisioningSessionResponse,
+ ProvisioningStepInput,
RainConsumerType,
RainContractResponseDto,
RainKycSubmitResponse,
@@ -2507,6 +2513,107 @@ export const fetchTokenList = async (params: SwapTokenRequest) => {
return response.data;
};
+// =====================================================================
+// Agent Wallet
+// =====================================================================
+
+const agentEndpoint = (path: string) =>
+ `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/agents${path}`;
+
+const agentJsonHeaders = () => {
+ const jwt = getJWTToken();
+ return {
+ 'Content-Type': 'application/json',
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ };
+};
+
+const postAgentJson = async (path: string, body?: unknown): Promise => {
+ const response = await fetch(agentEndpoint(path), {
+ method: 'POST',
+ headers: agentJsonHeaders(),
+ credentials: 'include',
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ if (!response.ok) throw response;
+ return response.json();
+};
+
+export const provisionAgentInit = (): Promise =>
+ postAgentJson('/provision/init');
+
+export const provisionAgentWalletAccount = (
+ input: ProvisioningStepInput,
+): Promise<{ activity: ProvisioningActivity }> => postAgentJson('/provision/wallet-account', input);
+
+export const provisionAgentUser = (
+ input: ProvisioningStepInput,
+): Promise<{ activity: ProvisioningActivity }> => postAgentJson('/provision/user', input);
+
+export const provisionAgentPolicy = (
+ input: ProvisioningStepInput,
+): Promise<{ agentEoaAddress: string }> => postAgentJson('/provision/policy', input);
+
+export const fetchAgent = async (): Promise => {
+ const response = await fetch(agentEndpoint('/me'), {
+ method: 'GET',
+ headers: agentJsonHeaders(),
+ credentials: 'include',
+ });
+ if (!response.ok) throw response;
+ return response.json();
+};
+
+/**
+ * Returns true iff the user has at least one successful AGENT_WALLET_DEPOSIT
+ * activity. Cached on the UI side to avoid re-querying on every render.
+ */
+export const fetchAgentHasDeposited = async (): Promise => {
+ const jwt = getJWTToken();
+ const url = `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/activity?scope=agent&type=agent_wallet_deposit&status=success&limit=1`;
+ const response = await fetch(url, {
+ headers: {
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ },
+ credentials: 'include',
+ });
+ if (!response.ok) throw response;
+ const json = (await response.json()) as { totalDocs?: number; docs?: unknown[] };
+ return (json.totalDocs ?? json.docs?.length ?? 0) > 0;
+};
+
+export const fetchAgentApiKeys = async (): Promise => {
+ const response = await fetch(agentEndpoint('/me/api-keys'), {
+ method: 'GET',
+ headers: agentJsonHeaders(),
+ credentials: 'include',
+ });
+ if (!response.ok) throw response;
+ return response.json();
+};
+
+export const generateAgentApiKey = async (name?: string): Promise => {
+ const response = await fetch(agentEndpoint('/me/api-keys'), {
+ method: 'POST',
+ headers: agentJsonHeaders(),
+ credentials: 'include',
+ body: JSON.stringify({ name }),
+ });
+ if (!response.ok) throw response;
+ return response.json();
+};
+
+export const revokeAgentApiKey = async (id: string): Promise => {
+ const response = await fetch(agentEndpoint(`/me/api-keys/${id}`), {
+ method: 'DELETE',
+ headers: agentJsonHeaders(),
+ credentials: 'include',
+ });
+ if (!response.ok) throw response;
+};
+
export const fetchAddressBook = async (): Promise => {
const jwt = getJWTToken();
const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/address-book`, {
diff --git a/lib/types.ts b/lib/types.ts
index e59e6f65..30e9afb1 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -690,6 +690,8 @@ export enum TransactionType {
REPAY_AND_WITHDRAW_COLLATERAL = 'repay_and_withdraw_collateral',
WITHDRAW_COLLATERAL = 'withdraw_collateral',
RESCUE_TOKEN = 'rescue_token',
+ AGENT_X402_PAYMENT = 'agent_x402_payment',
+ AGENT_WALLET_DEPOSIT = 'agent_wallet_deposit',
}
export enum TransactionDirection {
@@ -1549,6 +1551,54 @@ export interface AddressBookResponse {
skipped2faAt?: Date;
}
+export type AgentSummary = {
+ agentEoaAddress?: string;
+};
+
+export type AgentApiKeySummary = {
+ id: string;
+ prefix: string;
+ name?: string;
+ createdAt: string;
+ lastUsedAt?: string;
+ revokedAt?: string;
+};
+
+export type GenerateAgentApiKeyResponse = AgentApiKeySummary & { key: string };
+
+/**
+ * Envelope returned by the Turnkey SDK's `stampX` methods. `body` is the
+ * exact stringified bytes the SDK signed — we MUST forward it verbatim;
+ * re-serializing on the server changes key order and breaks the stamp.
+ */
+export type SignedTurnkeyRequest = {
+ url: string;
+ body: string;
+ stamp: { stampHeaderName: string; stampHeaderValue: string };
+};
+
+export type ProvisioningActivity = {
+ url: string;
+ body: Record;
+};
+
+export type ProvisioningInitResponse = {
+ provisioningId: string;
+ subOrganizationId: string;
+ /**
+ * Set when the agent's wallet path was already derived in Turnkey from a
+ * prior failed provisioning attempt. The `activity` in this case is the
+ * createUsers body — the client should skip the wallet-account stamp.
+ */
+ agentEoaAddress?: string;
+ activity: ProvisioningActivity;
+};
+
+export type ProvisioningStepInput = {
+ provisioningId: string;
+ signed: SignedTurnkeyRequest;
+};
+
export interface WhatsNewStep {
imageUrl: string;
title: string;
diff --git a/lib/utils/borrowAndBridge.ts b/lib/utils/borrowAndBridge.ts
new file mode 100644
index 00000000..8543fb29
--- /dev/null
+++ b/lib/utils/borrowAndBridge.ts
@@ -0,0 +1,256 @@
+import * as Sentry from '@sentry/react-native';
+import { Address } from 'abitype';
+import { Chain, erc20Abi, pad, TransactionReceipt } from 'viem';
+import { readContract } from 'viem/actions';
+import { fuse, mainnet } from 'viem/chains';
+import { encodeFunctionData, parseUnits } from 'viem/utils';
+
+import { USDC_STARGATE } from '@/constants/addresses';
+import { useActivityActions } from '@/hooks/useActivityActions';
+import { AaveV3Pool_ABI } from '@/lib/abis/AaveV3Pool';
+import BridgePayamster_ABI from '@/lib/abis/BridgePayamster';
+import { CardDepositManager_ABI } from '@/lib/abis/CardDepositManager';
+import { ADDRESSES } from '@/lib/config';
+import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute';
+import { StargateQuoteParams, TransactionType } from '@/lib/types';
+import { getStargateChainId, getStargateQuote } from '@/lib/utils/stargate';
+import { publicClient } from '@/lib/wagmi';
+
+import type { SmartAccountClient } from 'permissionless';
+
+// EIP-3009 / Aave LTV — keep one source of truth shared by every flow that
+// borrows USDC against soUSD on Fuse and bridges via Stargate to a chosen
+// destination address.
+const SO_USD_LTV = 70n;
+
+// AccountantWithRateProviders.getRate() — shared between card + agent flows.
+const ACCOUNTANT_ABI = [
+ {
+ inputs: [],
+ name: 'getRate',
+ outputs: [{ internalType: 'uint256', name: 'rate', type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+] as const;
+
+export interface BorrowAndBridgeUser {
+ safeAddress: string;
+ suborgId: string;
+ signWith: string;
+ userId: string;
+}
+
+export interface BorrowAndBridgeParams {
+ /** Connected user (must have safeAddress + AA signer context). */
+ user: BorrowAndBridgeUser;
+ /** Receiver of bridged USDC on the destination chain. */
+ destinationAddress: Address;
+ /** EVM chain id of the destination (e.g. base.id, arbitrum.id). */
+ destinationChainId: number;
+ /** Stargate's chain key for the destination ('base', 'arbitrum', ...). */
+ destinationChainKey: string;
+ /** USDC contract on the destination chain. */
+ destinationToken: Address;
+ /** Borrow amount as a human-readable USDC string (e.g. '10.5'). */
+ amountToBorrow: string;
+ /** AA signer factory used by the card flow (`useUser().safeAA`). */
+ safeAA: (chain: Chain, suborgId: string, signWith: string) => Promise;
+ /** Activity tracking — wires receipt + status into the in-app feed. */
+ trackTransaction: ReturnType['trackTransaction'];
+ /** Activity payload metadata. */
+ activityType: TransactionType;
+ activityTitle: string;
+ /** Optional Sentry/analytics breadcrumb tag (purely cosmetic). */
+ flowTag?: string;
+}
+
+/**
+ * Core "borrow USDC.e against soUSD on Fuse → Stargate-bridge to a
+ * destination" flow used by both the card-funding and agent-wallet
+ * deposit paths. The CardDepositManager is destination-agnostic (the
+ * receiver allowlist is gated by `isWhitelistEnabled` which is off in
+ * prod), so the same on-chain plumbing handles both.
+ */
+export async function executeBorrowAndBridge(
+ params: BorrowAndBridgeParams,
+): Promise {
+ const {
+ user,
+ destinationAddress,
+ destinationChainId,
+ destinationChainKey,
+ destinationToken,
+ amountToBorrow,
+ safeAA,
+ trackTransaction,
+ activityType,
+ activityTitle,
+ flowTag = 'borrow_and_bridge',
+ } = params;
+
+ const rate = await readContract(publicClient(mainnet.id), {
+ address: ADDRESSES.ethereum.accountant,
+ abi: ACCOUNTANT_ABI,
+ functionName: 'getRate',
+ });
+
+ const borrowAmountWei = parseUnits(amountToBorrow, 6);
+ const supplyAmountWei = (borrowAmountWei * 100n * 1000000n) / (SO_USD_LTV * rate);
+
+ const supplyApproveCalldata = encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [ADDRESSES.fuse.aaveV3Pool, supplyAmountWei],
+ });
+
+ const supplyCalldata = encodeFunctionData({
+ abi: AaveV3Pool_ABI,
+ functionName: 'supply',
+ args: [ADDRESSES.fuse.vault, supplyAmountWei, user.safeAddress as Address, 0],
+ });
+
+ const borrowCalldata = encodeFunctionData({
+ abi: AaveV3Pool_ABI,
+ functionName: 'borrow',
+ args: [USDC_STARGATE, borrowAmountWei, 2, 0, user.safeAddress as Address],
+ });
+
+ Sentry.addBreadcrumb({
+ message: `Starting ${flowTag} transaction`,
+ category: 'bridge',
+ data: {
+ amount: amountToBorrow,
+ amountWei: borrowAmountWei.toString(),
+ userAddress: user.safeAddress,
+ destinationAddress,
+ destinationChainId,
+ chainId: fuse.id,
+ },
+ });
+
+ // 5% slippage envelope on the destination amount.
+ const dstAmountMin = (borrowAmountWei * 95n) / 100n;
+
+ const quoteParams: StargateQuoteParams = {
+ srcToken: USDC_STARGATE,
+ srcChainKey: 'fuse',
+ dstToken: destinationToken,
+ dstChainKey: destinationChainKey,
+ srcAddress: ADDRESSES.fuse.bridgePaymasterAddress,
+ dstAddress: destinationAddress,
+ srcAmount: borrowAmountWei.toString(),
+ dstAmountMin: dstAmountMin.toString(),
+ };
+ const quote = await getStargateQuote(quoteParams);
+ const taxiQuote = quote.quotes.find(q => q.route.includes('taxi'));
+ if (!taxiQuote) throw new Error('Taxi route not available from Stargate');
+ if (taxiQuote.error) throw new Error(`Stargate quote error: ${taxiQuote.error}`);
+
+ const bridgeStep = taxiQuote.steps.find(step => step.type === 'bridge');
+ if (!bridgeStep) throw new Error('No bridge step found in Stargate quote');
+
+ const { transaction } = bridgeStep;
+ const nativeFeeAmount = BigInt(transaction.value);
+
+ const sendParam = {
+ dstEid: getStargateChainId(destinationChainId) as number,
+ to: pad(destinationAddress, { size: 32 }),
+ amountLD: borrowAmountWei,
+ minAmountLD: dstAmountMin,
+ extraOptions: '0x' as `0x${string}`,
+ composeMsg: '0x' as `0x${string}`,
+ oftCmd: '0x' as `0x${string}`,
+ };
+
+ const calldata = encodeFunctionData({
+ abi: CardDepositManager_ABI,
+ functionName: 'depositUsingStargate',
+ args: [
+ transaction.to as Address,
+ user.safeAddress as Address,
+ sendParam,
+ nativeFeeAmount,
+ ADDRESSES.fuse.bridgePaymasterAddress,
+ ],
+ });
+
+ const transactions = [
+ {
+ to: ADDRESSES.fuse.vault,
+ data: supplyApproveCalldata,
+ value: 0n,
+ },
+ {
+ to: ADDRESSES.fuse.aaveV3Pool,
+ data: supplyCalldata,
+ value: 0n,
+ },
+ {
+ to: ADDRESSES.fuse.aaveV3Pool,
+ data: borrowCalldata,
+ value: 0n,
+ },
+ // Approve USDC.e from Safe to CardDepositManager (manager is destination-agnostic).
+ {
+ to: USDC_STARGATE,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [ADDRESSES.fuse.cardDepositManager, borrowAmountWei],
+ }),
+ value: 0n,
+ },
+ // Forward the LZ native fee from BridgePaymaster (which is sponsored
+ // for the depositUsingStargate selector) and let the manager call
+ // Stargate's send().
+ {
+ to: ADDRESSES.fuse.bridgePaymasterAddress,
+ data: encodeFunctionData({
+ abi: BridgePayamster_ABI,
+ functionName: 'callWithValue',
+ args: [
+ ADDRESSES.fuse.cardDepositManager,
+ '0x37fe667d', // depositUsingStargate selector
+ calldata,
+ nativeFeeAmount,
+ ],
+ }),
+ value: 0n,
+ },
+ ];
+
+ const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith);
+
+ const result = await trackTransaction(
+ {
+ type: activityType,
+ title: activityTitle,
+ shortTitle: activityTitle,
+ amount: amountToBorrow,
+ symbol: 'USDC.e',
+ chainId: fuse.id,
+ fromAddress: user.safeAddress,
+ toAddress: destinationAddress,
+ metadata: {
+ description: `${activityTitle} ${amountToBorrow} USDC from Fuse to ${destinationAddress} on chain ${destinationChainId}`,
+ fee: transaction.value,
+ sourceSymbol: 'USDC.e',
+ tokenAddress: USDC_STARGATE,
+ },
+ },
+ onUserOpHash =>
+ executeTransactions(
+ smartAccountClient,
+ transactions,
+ `${activityTitle} failed`,
+ fuse,
+ onUserOpHash,
+ ),
+ );
+
+ const transactionResult =
+ result && typeof result === 'object' && 'transaction' in result ? result.transaction : result;
+
+ return transactionResult as TransactionReceipt | typeof USER_CANCELLED_TRANSACTION;
+}