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; +}