From bad45fbcab87806f1984f87735beb12985efa8b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 12:52:12 +0000 Subject: [PATCH 01/24] feat(agent): add Agent Wallet page, useAgent hooks, and API key integration UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the new /v1/agents/* backend endpoints into the UI: useAgentQuery, useProvisionAgent, useUpdateAgent, useAgentApiKeys, useGenerateAgentApiKey, useRevokeAgentApiKey. Add an Agent route accessible from the desktop nav that lets users provision an agent wallet, view the agent EOA / daily cap / allowlist, generate sk_solid_live_ API keys (one-time reveal), revoke keys, and copy a curl example + a Markdown system-prompt template designed to drop into Claude Desktop or ChatGPT custom GPTs. Surface agent activity types in the transaction-details map. Deposit flow is stubbed with a 'coming soon' toast — backend supports any path; client borrow-and-bridge wiring lands in a follow-up. (cherry picked from commit 99639c8111b82c8d228f6a2cd13b2e095065c50b) --- app/(protected)/agent/index.tsx | 177 ++++++++++++++++++++++++ components/Agent/ApiKeyList.tsx | 67 +++++++++ components/Agent/ApiKeyRevealModal.tsx | 65 +++++++++ components/Agent/IntegrationSnippet.tsx | 49 +++++++ constants/agentPromptTemplate.ts | 59 ++++++++ constants/path.ts | 2 + constants/transaction.ts | 8 ++ hooks/useAgent.ts | 99 +++++++++++++ hooks/useNav.ts | 7 +- lib/api.ts | 100 +++++++++++++ lib/types.ts | 4 + 11 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 app/(protected)/agent/index.tsx create mode 100644 components/Agent/ApiKeyList.tsx create mode 100644 components/Agent/ApiKeyRevealModal.tsx create mode 100644 components/Agent/IntegrationSnippet.tsx create mode 100644 constants/agentPromptTemplate.ts create mode 100644 hooks/useAgent.ts diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx new file mode 100644 index 000000000..a1afe4d1f --- /dev/null +++ b/app/(protected)/agent/index.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import Toast from 'react-native-toast-message'; + +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 { Switch } from '@/components/ui/switch'; +import { Text } from '@/components/ui/text'; +import { + useAgentApiKeys, + useAgentQuery, + useGenerateAgentApiKey, + useProvisionAgent, + useRevokeAgentApiKey, +} from '@/hooks/useAgent'; +import { eclipseAddress } from '@/lib/utils'; + +export default function AgentPage() { + const agentQuery = useAgentQuery(); + const provision = useProvisionAgent(); + const apiKeysQuery = useAgentApiKeys(); + const generateApiKey = useGenerateAgentApiKey(); + const revokeApiKey = useRevokeAgentApiKey(); + + const [revealedKey, setRevealedKey] = useState(null); + + const agent = agentQuery.data; + const isProvisioned = !!agent?.agentEoaAddress; + const hasDeposited = !!agent?.hasDepositedToAgentWallet; + + 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 ( + + + + Agent Wallet + + Your Solid Wallet is now Agentic + + + + {agentQuery.isLoading ? ( + + + + ) : !isProvisioned ? ( + + Set up your agent + + We'll create a new EOA under your existing Turnkey wallet, gated by a per-tx cap and + recipient allowlist. Idle USD principal stays in your soUSD vault and keeps earning + yield. + + + + ) : ( + <> + + Human + {}} disabled /> + Agent + + + + + Agent wallet address + + + + {eclipseAddress(agent.agentEoaAddress!, 8, 6)} + + + + Daily cap + + ${(agent.dailyCapUsdc / 1_000_000).toFixed(2)} + + + + Spent today + + ${(agent.dailySpentUsdc / 1_000_000).toFixed(2)} + + + + Allowlist + + {agent.recipientAllowlist.length} + + + + + + + + + + API Keys + + Authenticate AI tools that pay through your agent wallet. + + + + + revokeApiKey.mutate(id)} + revokingId={ + revokeApiKey.isPending ? (revokeApiKey.variables as string) : undefined + } + /> + + + + How to use + + + + )} + + setRevealedKey(null)} + apiKey={revealedKey} + /> + + + ); +} diff --git a/components/Agent/ApiKeyList.tsx b/components/Agent/ApiKeyList.tsx new file mode 100644 index 000000000..4d45e5665 --- /dev/null +++ b/components/Agent/ApiKeyList.tsx @@ -0,0 +1,67 @@ +import { ActivityIndicator, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { AgentApiKeySummary } from '@/lib/api'; + +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 000000000..b4bf74068 --- /dev/null +++ b/components/Agent/ApiKeyRevealModal.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; +import { View } from 'react-native'; + +import CopyToClipboard from '@/components/CopyToClipboard'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Text } from '@/components/ui/text'; + +type Props = { + open: boolean; + onClose: () => void; + apiKey: string | null; +}; + +const ApiKeyRevealModal = ({ open, onClose, apiKey }: Props) => { + const [confirmed, setConfirmed] = useState(false); + + useEffect(() => { + if (!open) setConfirmed(false); + }, [open]); + + return ( + !isOpen && onClose()}> + + + Your new API key + + 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} + {confirmed ? null : null} + + + ); +}; + +export default ApiKeyRevealModal; diff --git a/components/Agent/IntegrationSnippet.tsx b/components/Agent/IntegrationSnippet.tsx new file mode 100644 index 000000000..666751bed --- /dev/null +++ b/components/Agent/IntegrationSnippet.tsx @@ -0,0 +1,49 @@ +import { View } from 'react-native'; +import Toast from 'react-native-toast-message'; + +import CopyToClipboard from '@/components/CopyToClipboard'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { + AGENT_INTEGRATION_CURL, + AGENT_PROMPT_TEMPLATE, +} from '@/constants/agentPromptTemplate'; + +const IntegrationSnippet = () => { + const snippet = AGENT_INTEGRATION_CURL(); + return ( + + + Use these to wire up your AI tool. Paste the prompt template into Claude Desktop or + ChatGPT custom GPTs, and the curl example into a script or n8n node. + + + + + {snippet} + + + + + + + ); +}; + +export default IntegrationSnippet; diff --git a/constants/agentPromptTemplate.ts b/constants/agentPromptTemplate.ts new file mode 100644 index 000000000..9270e6307 --- /dev/null +++ b/constants/agentPromptTemplate.ts @@ -0,0 +1,59 @@ +/** + * Markdown system-prompt template that users can paste into Claude Desktop, + * ChatGPT custom GPTs, n8n nodes, or any other LLM tool. Tells the model + * how to call the Solid agent x402 endpoint. + */ +export const AGENT_PROMPT_TEMPLATE = `# Solid Agent Wallet — payment instructions + +You can pay USDC to allowlisted recipients via the Solid Agent Wallet API. Use this when the user asks you to pay for a paid resource that supports the x402 payment standard. + +## Authentication + +Send every request with: + +\`\`\` +Authorization: Bearer YOUR_SOLID_API_KEY +Content-Type: application/json +\`\`\` + +## Endpoint + +\`POST https://api.solid.xyz/v1/agents/me/x402-pay\` + +## 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. Must be in the user's allowlist. | +| \`description\` | string? | Optional human-readable note. | + +## Constraints + +- Recipient must already be on the user's allowlist. If you get a 403, ask the user to add the address from the Agent page first. +- Per-transaction cap and daily cap are enforced server-side. Daily resets at UTC midnight. +- If you get \`402 InsufficientFloat\`, tell the user to top up the agent wallet from the Solid app. + +## Example + +\`\`\`bash +curl -X POST https://api.solid.xyz/v1/agents/me/x402-pay \\ + -H "Authorization: Bearer YOUR_SOLID_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "resourceUrl": "https://api.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. +`; + +export const AGENT_INTEGRATION_CURL = (apiKeyHint = 'YOUR_SOLID_API_KEY') => + `curl -X POST https://api.solid.xyz/v1/agents/me/x402-pay \\ + -H "Authorization: Bearer ${apiKeyHint}" \\ + -H "Content-Type: application/json" \\ + -d '{"resourceUrl":"https://api.example.com/paid","amountUsdc":"100000","recipient":"0x..."}'`; diff --git a/constants/path.ts b/constants/path.ts index ddaaabd43..56b68b439 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 ff08b20be..f072410d2 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 000000000..9c732dc61 --- /dev/null +++ b/hooks/useAgent.ts @@ -0,0 +1,99 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import Toast from 'react-native-toast-message'; + +import { + AgentApiKeySummary, + AgentSummary, + GenerateAgentApiKeyResponse, + fetchAgent, + fetchAgentApiKeys, + generateAgentApiKey, + provisionAgent, + revokeAgentApiKey, + updateAgent, +} from '@/lib/api'; +import { withRefreshToken } from '@/lib/utils'; + +const AGENT_QUERY_KEY = ['agent'] as const; +const AGENT_API_KEYS_QUERY_KEY = ['agent', 'api-keys'] as const; + +export const useAgentQuery = () => + useQuery({ + queryKey: AGENT_QUERY_KEY, + queryFn: () => withRefreshToken(() => fetchAgent()), + staleTime: 60 * 1000, + }); + +export const useProvisionAgent = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => withRefreshToken(() => provisionAgent()), + 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: () => { + Toast.show({ + type: 'error', + text1: 'Failed to provision agent', + props: { badgeText: 'Error' }, + }); + }, + }); +}; + +export const useUpdateAgent = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { dailyCapUsdc?: number; recipientAllowlist?: string[] }) => + withRefreshToken(() => updateAgent(data)), + onSuccess: updated => { + queryClient.setQueryData(AGENT_QUERY_KEY, updated); + }, + }); +}; + +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/useNav.ts b/hooks/useNav.ts index 7c1623fe2..73e7b9bd9 100644 --- a/hooks/useNav.ts +++ b/hooks/useNav.ts @@ -28,12 +28,17 @@ 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, activity]; return { menuItems, }; diff --git a/lib/api.ts b/lib/api.ts index a06a284a9..bbea20d41 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -2507,6 +2507,106 @@ export const fetchTokenList = async (params: SwapTokenRequest) => { return response.data; }; +// ===================================================================== +// Agent Wallet +// ===================================================================== + +export type AgentSummary = { + agentEoaAddress?: string; + hasDepositedToAgentWallet: boolean; + dailyCapUsdc: number; + recipientAllowlist: string[]; + dailySpentUsdc: number; +}; + +export type AgentApiKeySummary = { + id: string; + prefix: string; + name?: string; + createdAt: string; + lastUsedAt?: string; + revokedAt?: string; +}; + +export type GenerateAgentApiKeyResponse = AgentApiKeySummary & { key: string }; + +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}` } : {}), + }; +}; + +export const provisionAgent = async (): Promise<{ agentEoaAddress: string }> => { + const response = await fetch(agentEndpoint('/provision'), { + method: 'POST', + headers: agentJsonHeaders(), + credentials: 'include', + }); + if (!response.ok) throw response; + return response.json(); +}; + +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(); +}; + +export const updateAgent = async ( + data: { dailyCapUsdc?: number; recipientAllowlist?: string[] }, +): Promise => { + const response = await fetch(agentEndpoint('/me'), { + method: 'PATCH', + headers: agentJsonHeaders(), + credentials: 'include', + body: JSON.stringify(data), + }); + if (!response.ok) throw response; + return response.json(); +}; + +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 e59e6f658..74d79bd4a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -168,6 +168,8 @@ export interface User { points?: number; credentialId?: string; externalWalletAddress?: string; + agentEoaAddress?: Address; + hasDepositedToAgentWallet?: boolean; } export type BlockscoutTransaction = { @@ -690,6 +692,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 { From 128dc8baf9f362d9169a93816cebf5f2e564a634 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 14:50:12 +0000 Subject: [PATCH 02/24] =?UTF-8?q?feat(agent):=20track=20simplified=20MVP?= =?UTF-8?q?=20=E2=80=94=20drop=20caps=20and=20allowlist,=20fetch=20on-chai?= =?UTF-8?q?n=20balance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move AgentSummary, AgentApiKeySummary, GenerateAgentApiKeyResponse from lib/api.ts to lib/types.ts. AgentSummary now only carries agentEoaAddress to match the simplified backend response. - Drop User.hasDepositedToAgentWallet — derived on the fly from the activity feed via useAgentDeposited (long-cached, one-way transition). - Drop useUpdateAgent (cap/allowlist removed from backend). - Add useAgentBalance: viem readContract on Base USDC for the agent EOA, refreshing every 60s. - Update Agent page copy from 'Idle USD principal stays in your soUSD vault and keeps earning' to 'Start earning yield on idle USDC in your agent wallet'. - Replace the cap/spent/allowlist counters card with a single 'USDC balance on Base' panel sourced from chain. (cherry picked from commit 32aed6bac28620f6d8b08c74f7bdec7283f591a2) --- app/(protected)/agent/index.tsx | 66 ++++++++++++++++----------------- hooks/useAgent.ts | 64 ++++++++++++++++++++++++-------- lib/api.ts | 44 +++++++++------------- lib/types.ts | 16 +++++++- 4 files changed, 113 insertions(+), 77 deletions(-) diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index a1afe4d1f..0e41bca59 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -12,6 +12,8 @@ import { Switch } from '@/components/ui/switch'; import { Text } from '@/components/ui/text'; import { useAgentApiKeys, + useAgentBalance, + useAgentDeposited, useAgentQuery, useGenerateAgentApiKey, useProvisionAgent, @@ -19,6 +21,15 @@ import { } from '@/hooks/useAgent'; 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, + })}`; +}; + export default function AgentPage() { const agentQuery = useAgentQuery(); const provision = useProvisionAgent(); @@ -26,11 +37,14 @@ export default function AgentPage() { const generateApiKey = useGenerateAgentApiKey(); const revokeApiKey = useRevokeAgentApiKey(); - const [revealedKey, setRevealedKey] = useState(null); - const agent = agentQuery.data; const isProvisioned = !!agent?.agentEoaAddress; - const hasDeposited = !!agent?.hasDepositedToAgentWallet; + + const balanceQuery = useAgentBalance(agent?.agentEoaAddress); + const depositedQuery = useAgentDeposited(isProvisioned); + const hasDeposited = depositedQuery.data ?? false; + + const [revealedKey, setRevealedKey] = useState(null); const handleProvision = async () => { try { @@ -71,9 +85,8 @@ export default function AgentPage() { Set up your agent - We'll create a new EOA under your existing Turnkey wallet, gated by a per-tx cap and - recipient allowlist. Idle USD principal stays in your soUSD vault and keeps earning - yield. + 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. + ['agent', 'balance', address?.toLowerCase()] as const; +const AGENT_DEPOSITED_QUERY_KEY = ['agent', 'has-deposited'] as const; + +const BASE_USDC_ADDRESS: Address = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; export const useAgentQuery = () => useQuery({ @@ -24,6 +34,41 @@ export const useAgentQuery = () => staleTime: 60 * 1000, }); +/** + * On-chain USDC balance for the agent EOA on Base. 6-decimal raw bigint. + */ +export const useAgentBalance = (agentEoaAddress?: string) => + useQuery({ + queryKey: AGENT_BALANCE_QUERY_KEY(agentEoaAddress), + enabled: !!agentEoaAddress, + queryFn: async () => { + if (!agentEoaAddress) return 0n; + const client = publicClient(base.id); + return client.readContract({ + address: BASE_USDC_ADDRESS, + abi: erc20Abi, + functionName: 'balanceOf', + args: [agentEoaAddress as Address], + }); + }, + staleTime: 30 * 1000, + refetchInterval: 60 * 1000, + }); + +/** + * 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, + }); + export const useProvisionAgent = () => { const queryClient = useQueryClient(); return useMutation({ @@ -48,17 +93,6 @@ export const useProvisionAgent = () => { }); }; -export const useUpdateAgent = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: { dailyCapUsdc?: number; recipientAllowlist?: string[] }) => - withRefreshToken(() => updateAgent(data)), - onSuccess: updated => { - queryClient.setQueryData(AGENT_QUERY_KEY, updated); - }, - }); -}; - export const useAgentApiKeys = () => useQuery({ queryKey: AGENT_API_KEYS_QUERY_KEY, diff --git a/lib/api.ts b/lib/api.ts index bbea20d41..b62e4a89f 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -24,6 +24,9 @@ import { ActivityEvents, AddressBookRequest, AddressBookResponse, + AgentApiKeySummary, + AgentSummary, + GenerateAgentApiKeyResponse, APYsByAsset, BridgeCustomerEndorsement, BridgeCustomerResponse, @@ -2511,25 +2514,6 @@ export const fetchTokenList = async (params: SwapTokenRequest) => { // Agent Wallet // ===================================================================== -export type AgentSummary = { - agentEoaAddress?: string; - hasDepositedToAgentWallet: boolean; - dailyCapUsdc: number; - recipientAllowlist: string[]; - dailySpentUsdc: number; -}; - -export type AgentApiKeySummary = { - id: string; - prefix: string; - name?: string; - createdAt: string; - lastUsedAt?: string; - revokedAt?: string; -}; - -export type GenerateAgentApiKeyResponse = AgentApiKeySummary & { key: string }; - const agentEndpoint = (path: string) => `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/agents${path}`; @@ -2562,17 +2546,23 @@ export const fetchAgent = async (): Promise => { return response.json(); }; -export const updateAgent = async ( - data: { dailyCapUsdc?: number; recipientAllowlist?: string[] }, -): Promise => { - const response = await fetch(agentEndpoint('/me'), { - method: 'PATCH', - headers: agentJsonHeaders(), +/** + * 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', - body: JSON.stringify(data), }); if (!response.ok) throw response; - return response.json(); + const json = (await response.json()) as { totalDocs?: number; docs?: unknown[] }; + return (json.totalDocs ?? json.docs?.length ?? 0) > 0; }; export const fetchAgentApiKeys = async (): Promise => { diff --git a/lib/types.ts b/lib/types.ts index 74d79bd4a..dfa2dc938 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -169,7 +169,6 @@ export interface User { credentialId?: string; externalWalletAddress?: string; agentEoaAddress?: Address; - hasDepositedToAgentWallet?: boolean; } export type BlockscoutTransaction = { @@ -1553,6 +1552,21 @@ 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 }; + export interface WhatsNewStep { imageUrl: string; title: string; From c11c74bb537591f063e45576eab866ff57914bb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 15:31:30 +0000 Subject: [PATCH 03/24] chore(agent): match useBalances polling cadence (5s + skip when backgrounded) (cherry picked from commit 2c09d656f8099cbe7832d0615ca1dfe52f3b7727) --- hooks/useAgent.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hooks/useAgent.ts b/hooks/useAgent.ts index 4f57334dd..ca44ed61a 100644 --- a/hooks/useAgent.ts +++ b/hooks/useAgent.ts @@ -36,6 +36,7 @@ export const useAgentQuery = () => /** * On-chain USDC balance for the agent EOA on Base. 6-decimal raw bigint. + * Mirrors the polling cadence used by useBalances for the Safe wallet. */ export const useAgentBalance = (agentEoaAddress?: string) => useQuery({ @@ -51,8 +52,9 @@ export const useAgentBalance = (agentEoaAddress?: string) => args: [agentEoaAddress as Address], }); }, - staleTime: 30 * 1000, - refetchInterval: 60 * 1000, + staleTime: 5_000, + refetchInterval: 5_000, + refetchIntervalInBackground: false, }); /** From 0e0b95c6834fe0e142bea0635d787b6fba3cb822 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 07:03:07 +0000 Subject: [PATCH 04/24] fix(agent): escape JSX apostrophes for react/no-unescaped-entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use ' in copy that was tripping CI lint: - 'We'll create a new EOA…' (Agent setup CTA) - 'This is the only time you'll see the full key…' (reveal modal description) - 'I've saved it' (reveal modal confirmation button) (cherry picked from commit 16a1e8ef486bf7fc91e31532887fb3a18d1a624a) --- app/(protected)/agent/index.tsx | 14 ++++---------- components/Agent/ApiKeyRevealModal.tsx | 11 ++++------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index 0e41bca59..5ad1fcd36 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -72,9 +72,7 @@ export default function AgentPage() { Agent Wallet - - Your Solid Wallet is now Agentic - + Your Solid Wallet is now Agentic {agentQuery.isLoading ? ( @@ -85,7 +83,7 @@ export default function AgentPage() { Set up your agent - We'll create a new EOA under your existing Turnkey wallet that can sign x402 + 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. ) : null} From 0580245f122b3202d2d0061c56cc111c0caa9adb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 07:48:13 +0000 Subject: [PATCH 05/24] fix(agent): act on UI code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reuse Base USDC address via getStargateToken(base.id) from lib/utils/stargate.ts instead of duplicating the literal in hooks/useAgent.ts. Keeps the canonical mapping single-sourced. - Match useBalances resilience options on useAgentBalance: gcTime, retry: 3 with exponential retryDelay, refetchOnWindowFocus, refetchOnReconnect. - Drop unused User.agentEoaAddress field from lib/types.ts — the agent address is sourced via useAgentQuery, not user hydration. - Remove dead { confirmed ? null : null } branch and the unused confirmed state from ApiKeyRevealModal.tsx. - Switch IntegrationSnippet's clipboard call from a dynamic import('expo-clipboard') inside the handler to a static import, matching the canonical pattern in components/CopyToClipboard.tsx. - Re-route AgentApiKeySummary import in ApiKeyList.tsx from @/lib/api (where it no longer lives) to @/lib/types. (cherry picked from commit 0f17a9bae7999ee44a9af862f78db68b8cf639de) --- components/Agent/ApiKeyList.tsx | 6 ++--- components/Agent/ApiKeyRevealModal.tsx | 15 +---------- components/Agent/IntegrationSnippet.tsx | 36 +++++++++++-------------- hooks/useAgent.ts | 26 ++++++++++-------- lib/api.ts | 6 ++--- lib/types.ts | 1 - 6 files changed, 35 insertions(+), 55 deletions(-) diff --git a/components/Agent/ApiKeyList.tsx b/components/Agent/ApiKeyList.tsx index 4d45e5665..e7236eb06 100644 --- a/components/Agent/ApiKeyList.tsx +++ b/components/Agent/ApiKeyList.tsx @@ -2,7 +2,7 @@ import { ActivityIndicator, View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { AgentApiKeySummary } from '@/lib/api'; +import { AgentApiKeySummary } from '@/lib/types'; type Props = { apiKeys: AgentApiKeySummary[] | undefined; @@ -54,9 +54,7 @@ const ApiKeyList = ({ apiKeys, isLoading, onRevoke, revokingId }: Props) => { disabled={revokingId === k.id} onPress={() => onRevoke(k.id)} > - - {revokingId === k.id ? 'Revoking…' : 'Revoke'} - + {revokingId === k.id ? 'Revoking…' : 'Revoke'} ))} diff --git a/components/Agent/ApiKeyRevealModal.tsx b/components/Agent/ApiKeyRevealModal.tsx index 3ef925868..d271cd421 100644 --- a/components/Agent/ApiKeyRevealModal.tsx +++ b/components/Agent/ApiKeyRevealModal.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react'; import { View } from 'react-native'; import CopyToClipboard from '@/components/CopyToClipboard'; @@ -19,12 +18,6 @@ type Props = { }; const ApiKeyRevealModal = ({ open, onClose, apiKey }: Props) => { - const [confirmed, setConfirmed] = useState(false); - - useEffect(() => { - if (!open) setConfirmed(false); - }, [open]); - return ( !isOpen && onClose()}> @@ -43,17 +36,11 @@ const ApiKeyRevealModal = ({ open, onClose, apiKey }: Props) => { - ) : null} - {confirmed ? null : null} ); diff --git a/components/Agent/IntegrationSnippet.tsx b/components/Agent/IntegrationSnippet.tsx index 666751bed..87168475a 100644 --- a/components/Agent/IntegrationSnippet.tsx +++ b/components/Agent/IntegrationSnippet.tsx @@ -1,21 +1,29 @@ import { View } from 'react-native'; import Toast from 'react-native-toast-message'; +import * as Clipboard from 'expo-clipboard'; import CopyToClipboard from '@/components/CopyToClipboard'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { - AGENT_INTEGRATION_CURL, - AGENT_PROMPT_TEMPLATE, -} from '@/constants/agentPromptTemplate'; +import { AGENT_INTEGRATION_CURL, AGENT_PROMPT_TEMPLATE } from '@/constants/agentPromptTemplate'; + +const handleCopyPrompt = async () => { + await Clipboard.setStringAsync(AGENT_PROMPT_TEMPLATE); + Toast.show({ + type: 'success', + text1: 'Prompt template copied', + text2: 'Paste into Claude Desktop or ChatGPT instructions', + props: { badgeText: 'Copied' }, + }); +}; const IntegrationSnippet = () => { const snippet = AGENT_INTEGRATION_CURL(); return ( - Use these to wire up your AI tool. Paste the prompt template into Claude Desktop or - ChatGPT custom GPTs, and the curl example into a script or n8n node. + Use these to wire up your AI tool. Paste the prompt template into Claude Desktop or ChatGPT + custom GPTs, and the curl example into a script or n8n node. @@ -25,21 +33,7 @@ const IntegrationSnippet = () => { - diff --git a/hooks/useAgent.ts b/hooks/useAgent.ts index ca44ed61a..2e9ee1244 100644 --- a/hooks/useAgent.ts +++ b/hooks/useAgent.ts @@ -1,5 +1,5 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import Toast from 'react-native-toast-message'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Address, erc20Abi } from 'viem'; import { base } from 'viem/chains'; @@ -11,12 +11,9 @@ import { provisionAgent, revokeAgentApiKey, } from '@/lib/api'; -import { - AgentApiKeySummary, - AgentSummary, - GenerateAgentApiKeyResponse, -} from '@/lib/types'; +import { AgentApiKeySummary, AgentSummary, GenerateAgentApiKeyResponse } 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; @@ -25,7 +22,9 @@ const AGENT_BALANCE_QUERY_KEY = (address?: string) => ['agent', 'balance', address?.toLowerCase()] as const; const AGENT_DEPOSITED_QUERY_KEY = ['agent', 'has-deposited'] as const; -const BASE_USDC_ADDRESS: Address = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; +// 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({ @@ -36,23 +35,28 @@ export const useAgentQuery = () => /** * On-chain USDC balance for the agent EOA on Base. 6-decimal raw bigint. - * Mirrors the polling cadence used by useBalances for the Safe wallet. + * 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, + enabled: !!agentEoaAddress && !!BASE_USDC_ADDRESS, queryFn: async () => { - if (!agentEoaAddress) return 0n; const client = publicClient(base.id); return client.readContract({ - address: BASE_USDC_ADDRESS, + 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, }); diff --git a/lib/api.ts b/lib/api.ts index b62e4a89f..ce8d3f849 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -26,7 +26,6 @@ import { AddressBookResponse, AgentApiKeySummary, AgentSummary, - GenerateAgentApiKeyResponse, APYsByAsset, BridgeCustomerEndorsement, BridgeCustomerResponse, @@ -61,6 +60,7 @@ import { ExtensionCardsResponse, FromCurrency, FullRewardsConfig, + GenerateAgentApiKeyResponse, GetLifiQuoteParams, HistoricalAPYPoint, HoldingFundsPointsMultiplierConfig, @@ -2575,9 +2575,7 @@ export const fetchAgentApiKeys = async (): Promise => { return response.json(); }; -export const generateAgentApiKey = async ( - name?: string, -): Promise => { +export const generateAgentApiKey = async (name?: string): Promise => { const response = await fetch(agentEndpoint('/me/api-keys'), { method: 'POST', headers: agentJsonHeaders(), diff --git a/lib/types.ts b/lib/types.ts index dfa2dc938..c2cc94196 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -168,7 +168,6 @@ export interface User { points?: number; credentialId?: string; externalWalletAddress?: string; - agentEoaAddress?: Address; } export type BlockscoutTransaction = { From cb686b76fde9b75dc73d913538496a2e2cfb4362 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 09:55:23 +0000 Subject: [PATCH 06/24] fix(agent): hide expo-router 'agent/index' header, restyle setup card - Register agent/index in the protected Stack with headerShown: false so the default 'agent/index' header strip stops rendering above the page (matches every other route in (protected)). - Restyle the 'Set up your agent' empty state to mirror the /card/pending box (rounded-2xl, border-white/5, bg-[#1C1C1C], padded px-6 pb-8 pt-10, centered title + description). Skipping the image per the screenshot direction; full-width primary CTA at the bottom. (cherry picked from commit 73f4f316d746c9ce4936d4e453e6a44435379c57) --- app/(protected)/_layout.tsx | 6 ++++++ app/(protected)/agent/index.tsx | 14 ++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/(protected)/_layout.tsx b/app/(protected)/_layout.tsx index 3227acc5a..16643d781 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 index 5ad1fcd36..8254e3fbb 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -80,13 +80,19 @@ export default function AgentPage() { ) : !isProvisioned ? ( - - Set up your agent - + + + Set up your agent + + 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. - From 9c3fc5a12f98d8f758da910dd1f3e4f0f2b2fb7c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 09:59:13 +0000 Subject: [PATCH 07/24] style(agent): use brand-variant button on setup card to match /card/ready (cherry picked from commit e2b3f863b56294b4683f55b49b03681246aa0dc5) --- app/(protected)/agent/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index 8254e3fbb..7f3125111 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -89,11 +89,14 @@ export default function AgentPage() { payments on Base. Start earning yield on idle USDC in your agent wallet. ) : ( From 7dbffcbe1cda6a0c52382063b98d8511d3cc5bfc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 10:53:13 +0000 Subject: [PATCH 08/24] style(agent): match /card/pending layout for empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the Agent Wallet heading + description from the empty state so the setup card stands alone (only render them once provisioned). - Narrow the empty state container to max-w-lg / pt-8 to mirror /card/pending; keep max-w-3xl + py-6/md:py-10 once provisioned. - Reserve a 268px-tall placeholder block above the title — same height as /card/pending's card-fade illustration, so the layout doesn't jump when we swap in the real artwork later. - Rename CTA + box title from 'Set up Agent' / 'Set up your agent' to 'Set up your Agent Wallet'. Provisioning creates the EOA itself, the Turnkey user, and the policy in one step — there's no separate 'agent' without a wallet, so the longer name is more accurate. (cherry picked from commit c417daf849be1712a6155db4ee2ac1e96f5face0) --- app/(protected)/agent/index.tsx | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index 7f3125111..4621b8ad1 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -69,11 +69,21 @@ export default function AgentPage() { return ( - - - Agent Wallet - Your Solid Wallet is now Agentic - + + {isProvisioned ? ( + + Agent Wallet + + Your Solid Wallet is now Agentic + + + ) : null} {agentQuery.isLoading ? ( @@ -81,8 +91,11 @@ export default function AgentPage() { ) : !isProvisioned ? ( + {/* Placeholder for hero artwork — keep the height stable so the + later swap to a real Image doesn't shift the layout. */} + - Set up your agent + Set up your Agent Wallet We'll create a new EOA under your existing Turnkey wallet that can sign x402 @@ -95,7 +108,7 @@ export default function AgentPage() { className="mt-6 h-12 w-full rounded-xl" > - {provision.isPending ? 'Setting up…' : 'Set up Agent'} + {provision.isPending ? 'Setting up…' : 'Set up Agent Wallet'} From df900cd8712c4af0660bbe90b55c201e85ee9b33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 11:02:46 +0000 Subject: [PATCH 09/24] feat(agent): add ?status= debug override to force render-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hit /agent?status=loading | not_provisioned | provisioned | deposited to force the page into a specific branch without going through real provisioning + activity flow. Useful for visual QA on the empty state, provisioned state, and the Switch's deposited variant. - Override is parsed via useLocalSearchParams and gated on !isProduction — the param is silently ignored on prod builds. - Live useAgent* hooks keep firing under the override so toggling the param off cleanly reverts to real data without a remount. - 'provisioned' / 'deposited' supply a stub agent EOA, a fake 2.34 USDC balance, and (for 'deposited') flip the Human/Agent Switch to the right side. (cherry picked from commit fb2f4e5ee1c27fe366f97dcbeef7ec026555cd64) --- app/(protected)/agent/index.tsx | 69 ++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index 4621b8ad1..f527d80c3 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { ActivityIndicator, View } from 'react-native'; import Toast from 'react-native-toast-message'; +import { useLocalSearchParams } from 'expo-router'; import ApiKeyList from '@/components/Agent/ApiKeyList'; import ApiKeyRevealModal from '@/components/Agent/ApiKeyRevealModal'; @@ -19,6 +20,7 @@ import { useProvisionAgent, useRevokeAgentApiKey, } from '@/hooks/useAgent'; +import { isProduction } from '@/lib/config'; import { eclipseAddress } from '@/lib/utils'; const formatUsdc = (raw?: bigint) => { @@ -30,19 +32,68 @@ const formatUsdc = (raw?: bigint) => { })}`; }; +/** + * Render-state override for visual debugging. Pass via query param: + * /agent?status=loading + * /agent?status=not_provisioned + * /agent?status=provisioned + * /agent?status=deposited (provisioned + Switch flipped + balance set) + * + * 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 + export default function AgentPage() { + const { status } = useLocalSearchParams<{ status?: string }>(); + 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 agent = agentQuery.data; - const isProvisioned = !!agent?.agentEoaAddress; + const liveAgent = agentQuery.data; + const liveIsProvisioned = !!liveAgent?.agentEoaAddress; + + // Derived view-model: the override wins when set, otherwise we use live + // backend data. Keeping the live hooks running unconditionally so the + // override can be toggled on/off without remounting. + 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(agent?.agentEoaAddress); + const balanceQuery = useAgentBalance(agentEoaAddress); const depositedQuery = useAgentDeposited(isProvisioned); - const hasDeposited = depositedQuery.data ?? false; + const hasDeposited = + statusOverride === 'deposited' + ? true + : statusOverride === 'provisioned' + ? false + : (depositedQuery.data ?? false); + const balance = + statusOverride === 'deposited' || statusOverride === 'provisioned' + ? DEMO_BALANCE_USDC + : balanceQuery.data; + const balanceLoading = statusOverride === undefined ? balanceQuery.isLoading : false; const [revealedKey, setRevealedKey] = useState(null); @@ -85,7 +136,7 @@ export default function AgentPage() { ) : null} - {agentQuery.isLoading ? ( + {isLoading ? ( @@ -123,19 +174,19 @@ export default function AgentPage() { Agent wallet address - + - {eclipseAddress(agent.agentEoaAddress!, 8, 6)} + {agentEoaAddress ? eclipseAddress(agentEoaAddress, 8, 6) : ''} USDC balance on Base - {balanceQuery.isLoading ? ( + {balanceLoading ? ( ) : ( - {formatUsdc(balanceQuery.data)} + {formatUsdc(balance)} )} - - - - - - API Keys - - Authenticate AI tools that pay through your agent wallet. - - - + + revokeApiKey.mutate(id)} + revokingId={ + revokeApiKey.isPending ? (revokeApiKey.variables as string) : undefined + } + /> - revokeApiKey.mutate(id)} - revokingId={revokeApiKey.isPending ? (revokeApiKey.variables as string) : undefined} - /> - - How to use + + How to use @@ -240,3 +202,162 @@ export default function AgentPage() { ); } + +interface ProvisionedHeaderProps { + isScreenMedium: boolean; + isGenerating: boolean; + onDeposit: () => void; + onGenerateApiKey: () => void; +} + +function ProvisionedHeader({ + isScreenMedium, + isGenerating, + onDeposit, + onGenerateApiKey, +}: ProvisionedHeaderProps) { + if (isScreenMedium) { + return ( + + + Agent Wallet + Your Solid Wallet is now Agentic + + + + + + + ); + } + + // Mobile: title above, circular action buttons below — same shape as + // the spendable-balance hero on /card/details. + return ( + + + Agent Wallet + Your Solid Wallet is now Agentic + + + } label="Deposit" onPress={onDeposit} /> + + ) : ( + + ) + } + label="Generate key" + onPress={onGenerateApiKey} + variant="dark" + disabled={isGenerating} + /> + + + ); +} + +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 WalletAddressCardProps { + address?: string; + balance?: bigint; + balanceLoading: boolean; +} + +function WalletAddressCard({ address, balance, balanceLoading }: WalletAddressCardProps) { + return ( + + + USDC balance on Base + {balanceLoading ? ( + + ) : ( + + {formatUsdc(balance)} + + )} + + + Agent wallet address + + + {address ? eclipseAddress(address, 8, 6) : ''} + + + + + + ); +} + +interface ApiKeysCardProps { + apiKeys: Parameters[0]['apiKeys']; + isLoading: boolean; + onRevoke: (id: string) => void; + revokingId?: string; +} + +function ApiKeysCard({ apiKeys, isLoading, onRevoke, revokingId }: ApiKeysCardProps) { + return ( + + + API Keys + + Authenticate AI tools that pay through your agent wallet. + + + + + ); +} diff --git a/components/Agent/ApiKeyList.tsx b/components/Agent/ApiKeyList.tsx index e7236eb06..04f19ac4d 100644 --- a/components/Agent/ApiKeyList.tsx +++ b/components/Agent/ApiKeyList.tsx @@ -20,7 +20,7 @@ const ApiKeyList = ({ apiKeys, isLoading, onRevoke, revokingId }: Props) => { if (isLoading) { return ( - + ); } @@ -28,7 +28,7 @@ const ApiKeyList = ({ apiKeys, isLoading, onRevoke, revokingId }: Props) => { 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. ); @@ -39,11 +39,11 @@ const ApiKeyList = ({ apiKeys, isLoading, onRevoke, revokingId }: Props) => { {active.map(k => ( - sk_solid_live_•••••{k.prefix} - + sk_solid_live_•••••{k.prefix} + {k.name ? `${k.name} · ` : ''}created {formatDate(k.createdAt)} {k.lastUsedAt ? ` · last used ${formatDate(k.lastUsedAt)}` : ''} diff --git a/components/Agent/ApiKeyRevealModal.tsx b/components/Agent/ApiKeyRevealModal.tsx index d271cd421..4881a888d 100644 --- a/components/Agent/ApiKeyRevealModal.tsx +++ b/components/Agent/ApiKeyRevealModal.tsx @@ -30,8 +30,8 @@ const ApiKeyRevealModal = ({ open, onClose, apiKey }: Props) => { {apiKey ? ( - - + + {apiKey} diff --git a/components/Agent/IntegrationSnippet.tsx b/components/Agent/IntegrationSnippet.tsx index 87168475a..5108bb760 100644 --- a/components/Agent/IntegrationSnippet.tsx +++ b/components/Agent/IntegrationSnippet.tsx @@ -25,9 +25,9 @@ const IntegrationSnippet = () => { Use these to wire up your AI tool. Paste the prompt template into Claude Desktop or ChatGPT custom GPTs, and the curl example into a script or n8n node. - + - + {snippet} From c1451c6d028c0345ca54cc7fe1c9d673d97f0850 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 12:46:29 +0000 Subject: [PATCH 11/24] feat(agent): wire borrow-against-savings deposit, header actions + balance gradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Borrow flow: - Extract the heavy lifting of useBorrowAndDepositToCard into a shared inner async helper at lib/utils/borrowAndBridge.ts. The function takes destinationAddress + destinationChainId/Key + destinationToken so the same on-chain transaction batch (approve soUSD → Aave supply → borrow USDC.e → approve CardDepositManager → BridgePaymaster callWithValue) handles any Stargate destination. - useBorrowAndDepositToCard becomes a thin wrapper that resolves the card-funding address and forwards to the helper. Existing tracking events + Sentry breadcrumbs preserved. - New useBorrowAndDepositToAgent wraps the same helper with the agent EOA + base.id + Stargate Base USDC, and emits AGENT_WALLET_DEPOSIT activities. - The CardDepositManager forwards regardless of receiver because isWhitelistEnabled is off in prod (verified: no caller of authorizeCards or setWhitelistEnabled in this repo or boring-vault), so we reuse the existing manager + sponsorship path on BridgePaymaster — no new contract, no new sponsor tx. Modal: - New AgentDepositModal: amount input, live borrow position (useAaveBorrowPosition), Max button capped at totalSupplied * LTV - totalBorrowed, brand-variant Borrow CTA. On submit invokes the new hook; toast on success/failure; modal closes on success. - Bound to the Agent page Deposit button (replaces the 'coming soon' toast). Mobile circular Deposit button opens the same modal. Page header: - Copy prompt button moved out of IntegrationSnippet and into the page-header button group, alongside Generate API key + Deposit. Uses the same secondary 'h-12 rounded-xl bg-[#303030] px-6' styling as Generate API key, with a FileText icon. Mobile gets a third dark CircleAction labelled 'Prompt'. - IntegrationSnippet now only renders the curl example + copy affordance and points users at the header for the prompt template. Balance card: - Replace the bg-[#1C1C1C] balance box with a /card/details-style SpendingBalanceCard: rounded-[20px], green LinearGradient overlay (rgba(104,216,82,…)), 50px USDC balance up top, 'Earning yield on idle USDC' caption underneath. Address moves into the API keys card so the balance card stays a hero panel. (cherry picked from commit 838d3353fb8d51d814709272535cb7f02dc5a94d) --- app/(protected)/agent/index.tsx | 124 +++++---- components/Agent/AgentDepositModal.tsx | 146 +++++++++++ components/Agent/IntegrationSnippet.tsx | 24 +- hooks/useBorrowAndDepositToAgent.ts | 97 +++++++ hooks/useBorrowAndDepositToCard.ts | 319 ++++-------------------- lib/utils/borrowAndBridge.ts | 256 +++++++++++++++++++ 6 files changed, 637 insertions(+), 329 deletions(-) create mode 100644 components/Agent/AgentDepositModal.tsx create mode 100644 hooks/useBorrowAndDepositToAgent.ts create mode 100644 lib/utils/borrowAndBridge.ts diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index 9902f20e9..2f459bbde 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -1,9 +1,12 @@ 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 { KeyRound, Plus } from 'lucide-react-native'; +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'; @@ -11,6 +14,7 @@ import CopyToClipboard from '@/components/CopyToClipboard'; import PageLayout from '@/components/PageLayout'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { AGENT_PROMPT_TEMPLATE } from '@/constants/agentPromptTemplate'; import { useAgentApiKeys, useAgentBalance, @@ -51,13 +55,15 @@ const VALID_OVERRIDES: AgentStatusOverride[] = [ const DEMO_AGENT_ADDRESS = '0x0000000000000000000000000000000000000000'; const DEMO_BALANCE_USDC = 12_345_670n; // $12.34 -const handleDepositComingSoon = () => +const handleCopyPrompt = async () => { + await Clipboard.setStringAsync(AGENT_PROMPT_TEMPLATE); Toast.show({ - type: 'info', - text1: 'Deposit flow coming soon', - text2: 'Will mirror borrow-against-savings: Aave borrow USDC on Fuse + Stargate to Base.', - props: { badgeText: 'Soon' }, + 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 }>(); @@ -76,9 +82,6 @@ export default function AgentPage() { const liveAgent = agentQuery.data; const liveIsProvisioned = !!liveAgent?.agentEoaAddress; - // Derived view-model: the override wins when set, otherwise we use live - // backend data. Keeping the live hooks running unconditionally so the - // override can be toggled on/off without remounting. const isLoading = statusOverride === 'loading' || (statusOverride === undefined && agentQuery.isLoading); const isProvisioned = @@ -98,6 +101,7 @@ export default function AgentPage() { const balanceLoading = statusOverride === undefined ? balanceQuery.isLoading : false; const [revealedKey, setRevealedKey] = useState(null); + const [depositOpen, setDepositOpen] = useState(false); const handleProvision = async () => { try { @@ -135,8 +139,6 @@ export default function AgentPage() { ) : !isProvisioned ? ( - {/* Placeholder for hero artwork — keep the height stable so the - later swap to a real Image doesn't shift the layout. */} Set up your Agent Wallet @@ -161,21 +163,18 @@ export default function AgentPage() { setDepositOpen(true)} onGenerateApiKey={handleGenerate} + onCopyPrompt={handleCopyPrompt} /> - {/* Wallet + API keys side-by-side on desktop, stacked on mobile */} - + revokeApiKey.mutate(id)} @@ -198,6 +197,11 @@ export default function AgentPage() { onClose={() => setRevealedKey(null)} apiKey={revealedKey} /> + setDepositOpen(false)} + agentEoaAddress={agentEoaAddress} + /> ); @@ -208,6 +212,7 @@ interface ProvisionedHeaderProps { isGenerating: boolean; onDeposit: () => void; onGenerateApiKey: () => void; + onCopyPrompt: () => void; } function ProvisionedHeader({ @@ -215,6 +220,7 @@ function ProvisionedHeader({ isGenerating, onDeposit, onGenerateApiKey, + onCopyPrompt, }: ProvisionedHeaderProps) { if (isScreenMedium) { return ( @@ -224,6 +230,16 @@ function ProvisionedHeader({ Your Solid Wallet is now Agentic + + + + + + + + + ); +}; + +export default AgentDepositModal; diff --git a/components/Agent/IntegrationSnippet.tsx b/components/Agent/IntegrationSnippet.tsx index 5108bb760..b384f09c2 100644 --- a/components/Agent/IntegrationSnippet.tsx +++ b/components/Agent/IntegrationSnippet.tsx @@ -1,29 +1,16 @@ import { View } from 'react-native'; -import Toast from 'react-native-toast-message'; -import * as Clipboard from 'expo-clipboard'; import CopyToClipboard from '@/components/CopyToClipboard'; -import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { AGENT_INTEGRATION_CURL, AGENT_PROMPT_TEMPLATE } from '@/constants/agentPromptTemplate'; - -const handleCopyPrompt = async () => { - await Clipboard.setStringAsync(AGENT_PROMPT_TEMPLATE); - Toast.show({ - type: 'success', - text1: 'Prompt template copied', - text2: 'Paste into Claude Desktop or ChatGPT instructions', - props: { badgeText: 'Copied' }, - }); -}; +import { AGENT_INTEGRATION_CURL } from '@/constants/agentPromptTemplate'; const IntegrationSnippet = () => { const snippet = AGENT_INTEGRATION_CURL(); return ( - - Use these to wire up your AI tool. Paste the prompt template into Claude Desktop or ChatGPT - custom GPTs, and the curl example into a script or n8n node. + + 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. @@ -33,9 +20,6 @@ const IntegrationSnippet = () => { - ); }; diff --git a/hooks/useBorrowAndDepositToAgent.ts b/hooks/useBorrowAndDepositToAgent.ts new file mode 100644 index 000000000..86a335eab --- /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 f1204674a..0fbfffa5b 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/lib/utils/borrowAndBridge.ts b/lib/utils/borrowAndBridge.ts new file mode 100644 index 000000000..8543fb29b --- /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; +} From f2012d619bbe92a0c292e199169d9b0ed6ecb75a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 14:08:17 +0000 Subject: [PATCH 12/24] feat(agent): ResponsiveModal deposit flow + centered header + blue balance card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modal: - Rebuild AgentDepositModal on top of ResponsiveModal with the same containerClassName='min-h-[42rem] overflow-y-auto flex-1' that the card deposit uses, so size + animation + header back-button behavior match. Inner content adopts CardDepositInternalForm primitives: bg-accent rounded-2xl px-5 py-{3,4} blocks, 'Amount to borrow' label + USDC token icon + 2xl input, 'Available to borrow / Max' row, and a small position breakdown (Currently borrowed, Supplied as collateral). Brand-variant Borrow CTA at the bottom. Provisioned page: - Bump max-width from max-w-5xl to max-w-7xl to match /card/details:150. - Center the desktop header (title block + button row stacked, both items-center) instead of the previous flex-row justify-between. - Bump page-level gap from gap-6 to gap-10 (gap-12 on md) so the header breathes above the boxes. - Add max-w-xl to IntegrationSnippet's description so the line length isn't card-wide on desktop. Balance card: - Switch the LinearGradient palette from green rgba(104,216,82,…) to blue rgba(74,144,226,1) → rgba(120,192,250,0.55) so the agent page reads distinctly from the green card surface, purple savings, and yellow rewards. - Rename label from 'USDC balance on Base' to 'Spendable balance' to match /card/details copy. - Add gap-12 between the balance and 'Earning' rows so the two blocks are visually separated, with min-h-[260px] floor to match the card card's minimum hero height. (cherry picked from commit 69e69a4f45ec29db5b811e952b7ea35099bf69db) --- app/(protected)/agent/index.tsx | 24 ++-- components/Agent/AgentDepositModal.tsx | 157 ++++++++++++++---------- components/Agent/IntegrationSnippet.tsx | 2 +- 3 files changed, 103 insertions(+), 80 deletions(-) diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index 2f459bbde..f2d8b7c41 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -129,7 +129,7 @@ export default function AgentPage() { @@ -224,8 +224,8 @@ function ProvisionedHeader({ }: ProvisionedHeaderProps) { if (isScreenMedium) { return ( - - + + Agent Wallet Your Solid Wallet is now Agentic @@ -330,31 +330,33 @@ interface BalanceCardProps { } /** - * Mirrors /card/details `SpendingBalanceCard` styling — green LinearGradient - * over a rounded-[20px] base, big balance up top, secondary stat under it. + * 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 ( - + - + - USDC balance on Base + Spendable balance {balanceLoading ? ( ) : ( - {formatted} + {formatted} )} - Earning + Earning Yield on idle USDC diff --git a/components/Agent/AgentDepositModal.tsx b/components/Agent/AgentDepositModal.tsx index 40f9f5bf7..a2fa132e4 100644 --- a/components/Agent/AgentDepositModal.tsx +++ b/components/Agent/AgentDepositModal.tsx @@ -1,23 +1,22 @@ import { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, TextInput, View } from 'react-native'; import Toast from 'react-native-toast-message'; +import { Image } from 'expo-image'; +import ResponsiveModal from '@/components/ResponsiveModal'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { Text } from '@/components/ui/text'; import { useAaveBorrowPosition } from '@/hooks/useAaveBorrowPosition'; import useBorrowAndDepositToAgent from '@/hooks/useBorrowAndDepositToAgent'; +import { getAsset } from '@/lib/assets'; import { Status } from '@/lib/types'; const SO_USD_LTV = 0.7; // mirror SO_USD_LTV in lib/utils/borrowAndBridge.ts const MIN_BORROW_USDC = 1; // smallest amount worth a userOp + Stargate fee +const MODAL_OPEN = { name: 'agent-deposit', number: 1 } as const; +const MODAL_CLOSE = { name: 'close', number: 0 } as const; + type Props = { open: boolean; onClose: () => void; @@ -66,80 +65,102 @@ const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { }; return ( - !isOpen && !submitting && onClose()}> - - - Deposit to Agent Wallet - - Borrow USDC against your soUSD savings on Fuse and bridge it to your agent EOA on Base - via Stargate. Idle savings keep earning yield. - - - - - - Available to borrow - {positionLoading ? ( - - ) : ( - - ${maxBorrowable.toLocaleString(undefined, { maximumFractionDigits: 2 })} - - )} - - Currently borrowed: $ - {totalBorrowed.toLocaleString(undefined, { maximumFractionDigits: 2 })} · Supplied: $ - {totalSupplied.toLocaleString(undefined, { maximumFractionDigits: 2 })} soUSD - - + { + if (!value && !submitting) onClose(); + }} + trigger={null} + title="Deposit to Agent Wallet" + containerClassName="min-h-[42rem] overflow-y-auto flex-1" + contentKey="agent-deposit" + > + + Borrow against Savings - - Amount (USDC) + {/* Amount input — same shape as Card's AmountInput */} + + Amount to borrow + - - + + USDC + USDC + + {/* Borrow position summary — mirror BalanceDisplay */} + + + Available to borrow + {positionLoading ? ( + + ) : ( + + ${maxBorrowable.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + )} + - - + + {/* Position breakdown */} + + + Currently borrowed + + ${totalBorrowed.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + + + Supplied as collateral + + ${totalSupplied.toLocaleString(undefined, { maximumFractionDigits: 2 })} soUSD + + + + + + + ); }; diff --git a/components/Agent/IntegrationSnippet.tsx b/components/Agent/IntegrationSnippet.tsx index b384f09c2..e56c8acbf 100644 --- a/components/Agent/IntegrationSnippet.tsx +++ b/components/Agent/IntegrationSnippet.tsx @@ -8,7 +8,7 @@ const IntegrationSnippet = () => { const snippet = AGENT_INTEGRATION_CURL(); 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. From e45c2fbf11f634cc277443b63f24a3ef7b83bdd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 14:40:40 +0000 Subject: [PATCH 13/24] style(agent): restore flex-row justify-between in desktop header Revert the items-center stack to the original layout (title left, buttons right) with mx-auto on the row so the whole header still sits centered within the max-w-7xl page wrapper. (cherry picked from commit fb607ef4b1aa05758207d348bbbf3451c7bfa955) --- app/(protected)/agent/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index f2d8b7c41..e3dbf60b2 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -224,8 +224,8 @@ function ProvisionedHeader({ }: ProvisionedHeaderProps) { if (isScreenMedium) { return ( - - + + Agent Wallet Your Solid Wallet is now Agentic From deb932220f8ee597c5ac915e5126806d0f0a954e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 15:15:25 +0000 Subject: [PATCH 14/24] style(agent): bump balance gradient first stop to rgb(0,117,255) (cherry picked from commit 913b92da470dbb9d81157ab11e5ebd9b97194192) --- app/(protected)/agent/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index e3dbf60b2..b706becca 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -340,7 +340,7 @@ function BalanceCard({ balance, balanceLoading }: BalanceCardProps) { return ( Date: Thu, 30 Apr 2026 07:38:55 +0000 Subject: [PATCH 15/24] feat(agent): clone CardDepositInternalForm BORROW source for agent deposit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bespoke amount-input + position-summary modal content with a direct clone of the card form's BORROW path: - BorrowSlider from components/Card/BorrowSlider for the amount input (slider + decimal input fused; min 0, max = totalSupplied * 0.69 - totalBorrowed, matching the card flow's CardDepositInternalForm:706). - TokenDetails block with Borrow rate (from useAaveBorrowPosition.borrowAPY) and Collateral Required (live computed from slider value), plus the same 'Use your soUSD as collateral … Learn more.' helper paragraph used on /card/details. - Bottom CTA reduced to a single brand-variant 'Deposit' button matching CardDepositInternalForm:548 — same h-12 rounded-2xl, ActivityIndicator swap when submitting. Page header: - Drop the wrapper's mx-auto (the page-level mx-auto on the max-w-7xl container already centers it). Switch items-end to items-center so the buttons align with the title text baseline rather than its descender. Balance gradient: - First stop now rgba(4, 96, 203, 1) per the latest spec; second stop unchanged. (cherry picked from commit 2fbc21de9a91b7c5284cd78858711c411e04b724) --- app/(protected)/agent/index.tsx | 4 +- components/Agent/AgentDepositModal.tsx | 165 +++++++++++++------------ 2 files changed, 85 insertions(+), 84 deletions(-) diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index b706becca..7b014395b 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -224,7 +224,7 @@ function ProvisionedHeader({ }: ProvisionedHeaderProps) { if (isScreenMedium) { return ( - + Agent Wallet Your Solid Wallet is now Agentic @@ -340,7 +340,7 @@ function BalanceCard({ balance, balanceLoading }: BalanceCardProps) { return ( { - const [amount, setAmount] = useState(''); - const { totalSupplied, totalBorrowed, isLoading: positionLoading } = useAaveBorrowPosition(); + const [sliderValue, setSliderValue] = useState(0); + const { + totalSupplied, + totalBorrowed, + borrowAPY, + isLoading: positionLoading, + } = useAaveBorrowPosition(); const { borrowAndDeposit, bridgeStatus } = useBorrowAndDepositToAgent(agentEoaAddress); - const maxBorrowable = useMemo( - () => Math.max(0, totalSupplied * SO_USD_LTV - totalBorrowed), + // Mirror /card/details borrow form: cap at 69% of supplied minus existing + // debt. The 69 vs 70 LTV is intentional to give a 1% safety margin against + // rounding when the on-chain supply value is computed. + const maxBorrowAmount = useMemo( + () => Math.max(0, totalSupplied * 0.69 - totalBorrowed), [totalSupplied, totalBorrowed], ); + const collateralRequired = useMemo(() => { + if (sliderValue <= 0) return 0; + // supply = borrow * 100 / (LTV * exchangeRate). soUSD ↔ USD is ~1:1 + // for the in-modal estimate. + return (sliderValue * 100) / Number(SO_USD_LTV); + }, [sliderValue]); + useEffect(() => { - if (!open) setAmount(''); + if (!open) setSliderValue(0); }, [open]); - const parsedAmount = Number(amount); - const amountValid = - !Number.isNaN(parsedAmount) && parsedAmount >= MIN_BORROW_USDC && parsedAmount <= maxBorrowable; const submitting = bridgeStatus === Status.PENDING; + const amountValid = sliderValue > 0 && sliderValue <= maxBorrowAmount; const handleSubmit = async () => { if (!amountValid || !agentEoaAddress) return; try { - await borrowAndDeposit(amount); + await borrowAndDeposit(sliderValue.toString()); Toast.show({ type: 'success', text1: 'Deposit submitted', @@ -77,87 +91,74 @@ const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { containerClassName="min-h-[42rem] overflow-y-auto flex-1" contentKey="agent-deposit" > - - Borrow against Savings + + + + - {/* Amount input — same shape as Card's AmountInput */} - - Amount to borrow - - - - USDC - USDC + + + Borrow rate + + {positionLoading ? ( + + ) : ( + + {formatNumber(borrowAPY, 2)}% + + )} - - - {/* Borrow position summary — mirror BalanceDisplay */} - - - Available to borrow - {positionLoading ? ( - - ) : ( - - ${maxBorrowable.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + + + Collateral Required - )} - - - - - {/* Position breakdown */} - - - Currently borrowed - - ${totalBorrowed.toLocaleString(undefined, { maximumFractionDigits: 2 })} - + + + {!sliderValue ? ( + + ) : ( + + {formatNumber(collateralRequired)} soUSD + + )} + - - Supplied as collateral - - ${totalSupplied.toLocaleString(undefined, { maximumFractionDigits: 2 })} 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. + - + From 368ad4e9e445ac96f1322920d580e86eee7a88a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 12:43:42 +0000 Subject: [PATCH 16/24] refactor(agent): session-stamped provisioning flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single POST /v1/agents/provision call with the four-step init → walletAccount → user → policy chain. We mint (or refresh) a Turnkey read-write session up front with one passkey gesture, then each step silently API-key-stamps the unsigned activity body the backend hands us and posts the signed envelope back. Backend relays the signed bytes to Turnkey verbatim, so the stamp still validates. This fixes the ORGANIZATION_MISMATCH error users hit on Set up Agent — the parent-org admin can't sign writes against existing sub-orgs. (cherry picked from commit 97a49011f94911ee561fa66db62e504a9ff1b6f3) --- hooks/useAgent.ts | 96 +++++++++++++++++++++++++++++++++++++++++++++-- lib/api.ts | 42 ++++++++++++++++++++- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/hooks/useAgent.ts b/hooks/useAgent.ts index 2e9ee1244..7def3910f 100644 --- a/hooks/useAgent.ts +++ b/hooks/useAgent.ts @@ -1,5 +1,6 @@ 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'; @@ -8,8 +9,12 @@ import { fetchAgentApiKeys, fetchAgentHasDeposited, generateAgentApiKey, - provisionAgent, + provisionAgentInit, + provisionAgentPolicy, + provisionAgentUser, + provisionAgentWalletAccount, revokeAgentApiKey, + type SignedTurnkeyRequest, } from '@/lib/api'; import { AgentApiKeySummary, AgentSummary, GenerateAgentApiKeyResponse } from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; @@ -75,10 +80,68 @@ export const useAgentDeposited = (enabled: boolean) => 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: () => withRefreshToken(() => provisionAgent()), + mutationFn: async () => { + // 1. Backend mints the provisioning record + first activity body. + const { provisioningId, activity: walletAcctActivity } = await withRefreshToken(() => + provisionAgentInit(), + ); + + // 2. Establish a Turnkey read-write session — one passkey gesture if + // we don't already have a live session with enough headroom. + await ensureSession({ getSession, refreshSession, loginWithPasskey }); + + if (!httpClient) { + throw new Error('Turnkey httpClient is not initialized'); + } + + // 3. Stamp + relay createWalletAccounts (silent; uses session API key). + const signed1 = await httpClient.stampCreateWalletAccounts( + walletAcctActivity.body as Parameters[0], + StamperType.ApiKey, + ); + if (!signed1) throw new Error('Failed to stamp createWalletAccounts'); + const { activity: usersActivity } = await provisionAgentWalletAccount({ + provisioningId, + signed: signed1 as SignedTurnkeyRequest, + }); + + // 4. Stamp + relay createUsers. + const signed2 = await httpClient.stampCreateUsers( + usersActivity.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'] }); @@ -89,16 +152,43 @@ export const useProvisionAgent = () => { props: { badgeText: 'Success' }, }); }, - onError: () => { + 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']; +}) => { + const existing = await deps.getSession(); + const nowSeconds = Date.now() / 1000; + if (existing && 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' }); +}; + export const useAgentApiKeys = () => useQuery({ queryKey: AGENT_API_KEYS_QUERY_KEY, diff --git a/lib/api.ts b/lib/api.ts index ce8d3f849..24940d774 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -2526,16 +2526,54 @@ const agentJsonHeaders = () => { }; }; -export const provisionAgent = async (): Promise<{ agentEoaAddress: string }> => { - const response = await fetch(agentEndpoint('/provision'), { +/** + * 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; +}; + +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<{ + provisioningId: string; + activity: ProvisioningActivity; +}> => postAgentJson('/provision/init'); + +export const provisionAgentWalletAccount = (input: { + provisioningId: string; + signed: SignedTurnkeyRequest; +}): Promise<{ activity: ProvisioningActivity }> => + postAgentJson('/provision/wallet-account', input); + +export const provisionAgentUser = (input: { + provisioningId: string; + signed: SignedTurnkeyRequest; +}): Promise<{ activity: ProvisioningActivity }> => postAgentJson('/provision/user', input); + +export const provisionAgentPolicy = (input: { + provisioningId: string; + signed: SignedTurnkeyRequest; +}): Promise<{ agentEoaAddress: string }> => postAgentJson('/provision/policy', input); + export const fetchAgent = async (): Promise => { const response = await fetch(agentEndpoint('/me'), { method: 'GET', From 11cb9136d581060dbf071baeeb09e62a5b341892 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 13:12:54 +0000 Subject: [PATCH 17/24] refactor(agent): hoist provisioning types out of api.ts into types.ts SignedTurnkeyRequest, ProvisioningActivity, ProvisioningInitResponse, and ProvisioningStepInput were declared inline next to the fetch helpers; move them to lib/types.ts to match the convention used by the rest of the file. useAgent.ts imports SignedTurnkeyRequest from there directly now instead of re-exporting via api.ts. (cherry picked from commit 7111e386e1f3d2d77ef2dcf753b1c4b9eaa825fa) --- hooks/useAgent.ts | 8 ++++++-- lib/api.ts | 47 ++++++++++++++--------------------------------- lib/types.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/hooks/useAgent.ts b/hooks/useAgent.ts index 7def3910f..fa142fd3b 100644 --- a/hooks/useAgent.ts +++ b/hooks/useAgent.ts @@ -14,9 +14,13 @@ import { provisionAgentUser, provisionAgentWalletAccount, revokeAgentApiKey, - type SignedTurnkeyRequest, } from '@/lib/api'; -import { AgentApiKeySummary, AgentSummary, GenerateAgentApiKeyResponse } from '@/lib/types'; +import { + AgentApiKeySummary, + AgentSummary, + GenerateAgentApiKeyResponse, + SignedTurnkeyRequest, +} from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; import { getStargateToken } from '@/lib/utils/stargate'; import { publicClient } from '@/lib/wagmi'; diff --git a/lib/api.ts b/lib/api.ts index 24940d774..89717a824 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -76,8 +76,11 @@ import { MppCredentialsResponse, Points, PromotionsBannerResponse, + ProvisioningActivity, + ProvisioningInitResponse, ProvisioningSessionRequest, ProvisioningSessionResponse, + ProvisioningStepInput, RainConsumerType, RainContractResponseDto, RainKycSubmitResponse, @@ -2526,22 +2529,6 @@ const agentJsonHeaders = () => { }; }; -/** - * 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; -}; - const postAgentJson = async (path: string, body?: unknown): Promise => { const response = await fetch(agentEndpoint(path), { method: 'POST', @@ -2553,26 +2540,20 @@ const postAgentJson = async (path: string, body?: unknown): Promise => { return response.json(); }; -export const provisionAgentInit = (): Promise<{ - provisioningId: string; - activity: ProvisioningActivity; -}> => postAgentJson('/provision/init'); +export const provisionAgentInit = (): Promise => + postAgentJson('/provision/init'); -export const provisionAgentWalletAccount = (input: { - provisioningId: string; - signed: SignedTurnkeyRequest; -}): Promise<{ activity: ProvisioningActivity }> => - postAgentJson('/provision/wallet-account', input); +export const provisionAgentWalletAccount = ( + input: ProvisioningStepInput, +): Promise<{ activity: ProvisioningActivity }> => postAgentJson('/provision/wallet-account', input); -export const provisionAgentUser = (input: { - provisioningId: string; - signed: SignedTurnkeyRequest; -}): Promise<{ activity: ProvisioningActivity }> => postAgentJson('/provision/user', input); +export const provisionAgentUser = ( + input: ProvisioningStepInput, +): Promise<{ activity: ProvisioningActivity }> => postAgentJson('/provision/user', input); -export const provisionAgentPolicy = (input: { - provisioningId: string; - signed: SignedTurnkeyRequest; -}): Promise<{ agentEoaAddress: string }> => postAgentJson('/provision/policy', input); +export const provisionAgentPolicy = ( + input: ProvisioningStepInput, +): Promise<{ agentEoaAddress: string }> => postAgentJson('/provision/policy', input); export const fetchAgent = async (): Promise => { const response = await fetch(agentEndpoint('/me'), { diff --git a/lib/types.ts b/lib/types.ts index c2cc94196..6e8f32b2b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1566,6 +1566,32 @@ export type AgentApiKeySummary = { 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; + activity: ProvisioningActivity; +}; + +export type ProvisioningStepInput = { + provisioningId: string; + signed: SignedTurnkeyRequest; +}; + export interface WhatsNewStep { imageUrl: string; title: string; From 35308be3a8a8e78a239a3ec9120a7200d221984a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 15:06:22 +0000 Subject: [PATCH 18/24] fix(agent): scope session to sub-org, skip step 2 on orphan adoption Two staging failures fixed: - loginWithPasskey now passes organizationId: subOrganizationId so the session API key is minted on the user's sub-org. Without this Turnkey rejects sub-org activities with PUBLIC_KEY_NOT_FOUND. - If init returns an agentEoaAddress, the user already had a derived wallet account from a prior failed attempt. We skip the createWalletAccounts stamp and use init's `activity` (createUsers body) directly. Also: ensureSession now invalidates a cached session if its organizationId doesn't match the user's sub-org, since switching scopes requires a fresh login. (cherry picked from commit 952e0f4cfb2192e2e23f316c23528643d5c179b6) --- hooks/useAgent.ts | 65 ++++++++++++++++++++++++++++++++--------------- lib/types.ts | 7 +++++ 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/hooks/useAgent.ts b/hooks/useAgent.ts index fa142fd3b..24664e78a 100644 --- a/hooks/useAgent.ts +++ b/hooks/useAgent.ts @@ -99,33 +99,49 @@ export const useProvisionAgent = () => { return useMutation({ mutationFn: async () => { - // 1. Backend mints the provisioning record + first activity body. - const { provisioningId, activity: walletAcctActivity } = await withRefreshToken(() => - provisionAgentInit(), - ); + // 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 — one passkey gesture if - // we don't already have a live session with enough headroom. - await ensureSession({ getSession, refreshSession, loginWithPasskey }); + // 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 (silent; uses session API key). - const signed1 = await httpClient.stampCreateWalletAccounts( - walletAcctActivity.body as Parameters[0], - StamperType.ApiKey, - ); - if (!signed1) throw new Error('Failed to stamp createWalletAccounts'); - const { activity: usersActivity } = await provisionAgentWalletAccount({ - provisioningId, - signed: signed1 as SignedTurnkeyRequest, - }); + // 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( - usersActivity.body as Parameters[0], + nextActivity.body as Parameters[0], StamperType.ApiKey, ); if (!signed2) throw new Error('Failed to stamp createUsers'); @@ -179,10 +195,16 @@ 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; - if (existing && existing.expiry > nowSeconds + SESSION_HEADROOM_SECONDS) { + // 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; @@ -190,7 +212,10 @@ const ensureSession = async (deps: { // Fall through to a fresh passkey login. } } - await deps.loginWithPasskey({ expirationSeconds: '900' }); + await deps.loginWithPasskey({ + expirationSeconds: '900', + organizationId: deps.organizationId, + }); }; export const useAgentApiKeys = () => diff --git a/lib/types.ts b/lib/types.ts index 6e8f32b2b..30e9afb11 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1584,6 +1584,13 @@ export type ProvisioningActivity = { 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; }; From 7aaafa64b778032ee8ee9a86cba1942b345b1d9b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 15:50:44 +0000 Subject: [PATCH 19/24] style(agent): use ResponsiveModal + brand button for API key reveal Match the rest of the agent flow: ResponsiveModal wrapper for mobile/web parity, brand-variant button at h-14 rounded-2xl like the email-OTP and swap modals. (cherry picked from commit 467be61d97ce1229f2856762f0177d16c4395e10) --- components/Agent/ApiKeyRevealModal.tsx | 57 +++++++++++++------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/components/Agent/ApiKeyRevealModal.tsx b/components/Agent/ApiKeyRevealModal.tsx index 4881a888d..78b868c42 100644 --- a/components/Agent/ApiKeyRevealModal.tsx +++ b/components/Agent/ApiKeyRevealModal.tsx @@ -1,14 +1,8 @@ import { View } from 'react-native'; import CopyToClipboard from '@/components/CopyToClipboard'; +import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { Text } from '@/components/ui/text'; type Props = { @@ -17,32 +11,39 @@ type Props = { 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()}> - - - Your new API key - - 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. - - + !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} - - - - + + + {apiKey} + + ) : null} - - + + + ); }; From a304a1270b56190bb132c0686d15d748bd0e7d45 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 16:16:51 +0000 Subject: [PATCH 20/24] feat(agent): rewrite prompt template + add external-wallet deposit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt template: - Endpoint URL is built from EXPO_PUBLIC_FLASH_API_BASE_URL with the required `/accounts/v1/...` path so AI tools call the live route instead of the legacy `/v1/...` (the latter 404s on accounts-qa). - Drop MVP-out-of-scope language: no per-tx caps, no daily caps, no recipient allowlist (none of these are enforced server-side anymore). - Explicit safety paragraph: pasting the live key into Claude Desktop / ChatGPT is fine — the key only authorizes USDC payments out of this one agent wallet, gated by a Turnkey policy. - First-turn behavior: the model should respond in 3-4 sentences, confirm setup, and suggest exactly 3 x402 stores/APIs relevant to the user as a "what to try first" prompt. No technical detail. Deposit modal: - Source picker (web only — thirdweb's connector flow is web-only), branching into the existing borrow-against-savings flow or a new external-wallet flow. - New AgentDepositExternalForm: connect via thirdweb, switch chain to Base, transfer USDC directly from the connected wallet to the agent EOA. Logs an AGENT_WALLET_DEPOSIT activity. No Stargate, no yield — the copy makes that explicit. Also: IntegrationSnippet now uses the same dynamic base URL. (cherry picked from commit 9c3c60d789fabe1495e9d768ba29bff7da9eb3e8) --- app/(protected)/agent/index.tsx | 7 +- components/Agent/AgentDepositExternalForm.tsx | 248 ++++++++++++++++ components/Agent/AgentDepositModal.tsx | 274 ++++++++++++------ components/Agent/IntegrationSnippet.tsx | 5 +- constants/agentPromptTemplate.ts | 68 +++-- 5 files changed, 479 insertions(+), 123 deletions(-) create mode 100644 components/Agent/AgentDepositExternalForm.tsx diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx index 7b014395b..12f501c30 100644 --- a/app/(protected)/agent/index.tsx +++ b/app/(protected)/agent/index.tsx @@ -14,7 +14,7 @@ import CopyToClipboard from '@/components/CopyToClipboard'; import PageLayout from '@/components/PageLayout'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { AGENT_PROMPT_TEMPLATE } from '@/constants/agentPromptTemplate'; +import { buildAgentPromptTemplate } from '@/constants/agentPromptTemplate'; import { useAgentApiKeys, useAgentBalance, @@ -24,7 +24,7 @@ import { useRevokeAgentApiKey, } from '@/hooks/useAgent'; import { useDimension } from '@/hooks/useDimension'; -import { isProduction } from '@/lib/config'; +import { EXPO_PUBLIC_FLASH_API_BASE_URL, isProduction } from '@/lib/config'; import { eclipseAddress } from '@/lib/utils'; const formatUsdc = (raw?: bigint) => { @@ -56,7 +56,8 @@ const DEMO_AGENT_ADDRESS = '0x0000000000000000000000000000000000000000'; const DEMO_BALANCE_USDC = 12_345_670n; // $12.34 const handleCopyPrompt = async () => { - await Clipboard.setStringAsync(AGENT_PROMPT_TEMPLATE); + const template = buildAgentPromptTemplate({ baseUrl: EXPO_PUBLIC_FLASH_API_BASE_URL }); + await Clipboard.setStringAsync(template); Toast.show({ type: 'success', text1: 'Prompt template copied', diff --git a/components/Agent/AgentDepositExternalForm.tsx b/components/Agent/AgentDepositExternalForm.tsx new file mode 100644 index 000000000..2e7a43c84 --- /dev/null +++ b/components/Agent/AgentDepositExternalForm.tsx @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { ActivityIndicator, TextInput, View } from 'react-native'; +import Toast from 'react-native-toast-message'; +import { Image } from 'expo-image'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Wallet as WalletIcon } from 'lucide-react-native'; +import { useActiveAccount, useSwitchActiveWalletChain } from 'thirdweb/react'; +import { Address, encodeFunctionData, erc20Abi, formatUnits, parseUnits } from 'viem'; +import { useReadContract } from 'wagmi'; +import { z } from 'zod'; + +import ConnectedWalletDropdown from '@/components/ConnectedWalletDropdown'; +import Max from '@/components/Max'; +import { Button } from '@/components/ui/button'; +import Skeleton from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useActivityActions } from '@/hooks/useActivityActions'; +import { getAsset } from '@/lib/assets'; +import { ADDRESSES } from '@/lib/config'; +import { getChain } from '@/lib/thirdweb'; +import { Status, TransactionStatus, TransactionType } from '@/lib/types'; +import { cn, formatNumber } from '@/lib/utils'; + +const BASE_CHAIN_ID = 8453; +const BASE_USDC_ADDRESS = ADDRESSES.base.usdc as Address; + +type FormData = { amount: string }; + +type Props = { + agentEoaAddress: string; + onSuccess: () => void; +}; + +const AgentDepositExternalForm = ({ agentEoaAddress, onSuccess }: Props) => { + const account = useActiveAccount(); + const switchChain = useSwitchActiveWalletChain(); + const { createActivity, updateActivity } = useActivityActions(); + const [sendStatus, setSendStatus] = useState(Status.IDLE); + + const eoaAddress = account?.address as Address | undefined; + const { data: eoaTokenBalance, isLoading: isEOABalanceLoading } = useReadContract({ + abi: erc20Abi, + address: BASE_USDC_ADDRESS, + functionName: 'balanceOf', + args: [eoaAddress as Address], + chainId: BASE_CHAIN_ID, + query: { enabled: !!eoaAddress }, + }); + + const formattedBalance = eoaTokenBalance ? formatUnits(eoaTokenBalance, 6) : '0'; + + const schema = useMemo(() => { + const balanceAmount = Number(formattedBalance); + return z.object({ + amount: z + .string() + .refine(val => val !== '' && !isNaN(Number(val)), { error: 'Enter a valid amount' }) + .refine(val => Number(val) > 0, { error: 'Amount must be greater than 0' }) + .refine(val => Number(val) <= balanceAmount, { + error: `Available balance is ${formatNumber(balanceAmount)} USDC`, + }), + }); + }, [formattedBalance]); + + const { control, handleSubmit, formState, watch, reset, setValue, trigger } = useForm({ + resolver: zodResolver(schema) as any, + mode: 'onChange', + defaultValues: { amount: '' }, + }); + + const watchedAmount = watch('amount'); + + // Reset form on unmount. + useEffect(() => () => reset(), [reset]); + + const onSubmit = useCallback( + async (data: FormData) => { + if (!account) { + Toast.show({ + type: 'error', + text1: 'Wallet not connected', + text2: 'Please connect your wallet to continue', + }); + return; + } + try { + setSendStatus(Status.PENDING); + + const baseChain = getChain(BASE_CHAIN_ID); + if (baseChain) { + try { + await switchChain(baseChain); + } catch (chainError) { + console.error('Failed to switch to Base:', chainError); + Toast.show({ + type: 'error', + text1: 'Network switch failed', + text2: 'Please manually switch your wallet to Base mainnet.', + }); + setSendStatus(Status.ERROR); + return; + } + } + + const amountWei = parseUnits(data.amount, 6); + const clientTxId = await createActivity({ + type: TransactionType.AGENT_WALLET_DEPOSIT, + title: 'Deposit to Agent Wallet', + shortTitle: 'Agent Deposit', + amount: data.amount, + symbol: 'USDC', + chainId: BASE_CHAIN_ID, + fromAddress: account.address, + toAddress: agentEoaAddress, + status: TransactionStatus.PENDING, + metadata: { + description: `Deposit ${data.amount} USDC to agent wallet from external wallet`, + tokenAddress: BASE_USDC_ADDRESS, + }, + }); + + const tx = await account.sendTransaction({ + chainId: BASE_CHAIN_ID, + to: BASE_USDC_ADDRESS, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [agentEoaAddress as Address, amountWei], + }), + value: 0n, + }); + + await updateActivity(clientTxId, { + status: TransactionStatus.SUCCESS, + hash: tx.transactionHash, + url: `https://basescan.org/tx/${tx.transactionHash}`, + metadata: { txHash: tx.transactionHash }, + }); + + setSendStatus(Status.SUCCESS); + Toast.show({ + type: 'success', + text1: 'Deposit sent', + text2: 'USDC transferred to your agent wallet on Base.', + props: { badgeText: 'Success' }, + }); + reset(); + onSuccess(); + } catch (error) { + setSendStatus(Status.ERROR); + console.error('External agent deposit error:', error); + Toast.show({ + type: 'error', + text1: 'Deposit failed', + text2: 'Please try again or check your wallet balance.', + }); + } + }, + [account, agentEoaAddress, switchChain, createActivity, updateActivity, reset, onSuccess], + ); + + const submitting = sendStatus === Status.PENDING; + const disabled = submitting || !formState.isValid || !watchedAmount || !account; + + return ( + + + From wallet + + + + + Deposit amount + + ( + + )} + /> + + USDC + USDC + + + {formState.errors.amount && ( + {formState.errors.amount.message} + )} + + + {isEOABalanceLoading ? ( + + ) : ( + + {formatNumber(Number(formattedBalance))} USDC + + )} + { + setValue('amount', formattedBalance); + trigger('amount'); + }} + /> + + + Sends USDC directly on Base. Funds arrive at your agent wallet immediately and do not earn + yield — borrow against savings instead if you want to keep yield on the principal. + + + + + + + + ); +}; + +export default AgentDepositExternalForm; diff --git a/components/Agent/AgentDepositModal.tsx b/components/Agent/AgentDepositModal.tsx index da6b8c619..f36e0fa6c 100644 --- a/components/Agent/AgentDepositModal.tsx +++ b/components/Agent/AgentDepositModal.tsx @@ -1,9 +1,11 @@ -import { useEffect, useMemo, useState } from 'react'; -import { ActivityIndicator, Linking, View } from 'react-native'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Linking, Platform, View } from 'react-native'; import Toast from 'react-native-toast-message'; +import { ChevronRight, Wallet } from 'lucide-react-native'; +import AgentDepositExternalForm from '@/components/Agent/AgentDepositExternalForm'; import { BorrowSlider } from '@/components/Card/BorrowSlider'; -import ResponsiveModal from '@/components/ResponsiveModal'; +import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; import TokenDetails from '@/components/TokenCard/TokenDetails'; import { Button } from '@/components/ui/button'; import Skeleton from '@/components/ui/skeleton'; @@ -15,8 +17,14 @@ import { formatNumber } from '@/lib/utils'; const SO_USD_LTV = 70n; -const MODAL_OPEN = { name: 'agent-deposit', number: 1 } as const; -const MODAL_CLOSE = { name: 'close', number: 0 } as const; +type Step = 'options' | 'borrow' | 'external'; + +const STEP_NUMBER: Record = { options: 1, borrow: 2, external: 2 }; +const stepState = (step: Step): ModalState => ({ + name: `agent-deposit-${step}`, + number: STEP_NUMBER[step], +}); +const CLOSE_STATE: ModalState = { name: 'close', number: 0 }; type Props = { open: boolean; @@ -25,6 +33,108 @@ type Props = { }; const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { + // External-wallet path is web-only — thirdweb's connector flow doesn't run + // inside the React Native runtime. Mobile users keep the borrow path. + const externalEnabled = Platform.OS === 'web'; + + const [step, setStep] = useState(externalEnabled ? 'options' : 'borrow'); + const [previousStep, setPreviousStep] = useState(step); + + useEffect(() => { + if (!open) { + setStep(externalEnabled ? 'options' : 'borrow'); + setPreviousStep(externalEnabled ? 'options' : 'borrow'); + } + }, [open, externalEnabled]); + + const goTo = useCallback( + (next: Step) => { + setPreviousStep(step); + setStep(next); + }, + [step], + ); + + const title = useMemo(() => { + if (step === 'options') return 'Deposit to Agent Wallet'; + if (step === 'borrow') return 'Borrow against savings'; + return 'Deposit from external wallet'; + }, [step]); + + const showBack = step !== 'options' && externalEnabled; + + return ( + { + if (!value) onClose(); + }} + trigger={null} + title={title} + contentKey={`agent-deposit-${step}`} + containerClassName="min-h-[42rem] overflow-y-auto flex-1" + showBackButton={showBack} + onBackPress={() => goTo('options')} + > + {step === 'options' && externalEnabled ? ( + + ) : step === 'external' && agentEoaAddress ? ( + + ) : ( + + )} + + ); +}; + +const DepositOptions = ({ onSelect }: { onSelect: (step: Step) => void }) => ( + + onSelect('borrow')} + /> + onSelect('external')} + /> + +); + +const OptionItem = ({ + title, + description, + onPress, +}: { + title: string; + description: string; + onPress: () => void; +}) => ( + +); + +type BorrowFormProps = { + agentEoaAddress?: string; + onSuccess: () => void; +}; + +const AgentDepositBorrowForm = ({ agentEoaAddress, onSuccess }: BorrowFormProps) => { const [sliderValue, setSliderValue] = useState(0); const { totalSupplied, @@ -34,9 +144,6 @@ const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { } = useAaveBorrowPosition(); const { borrowAndDeposit, bridgeStatus } = useBorrowAndDepositToAgent(agentEoaAddress); - // Mirror /card/details borrow form: cap at 69% of supplied minus existing - // debt. The 69 vs 70 LTV is intentional to give a 1% safety margin against - // rounding when the on-chain supply value is computed. const maxBorrowAmount = useMemo( () => Math.max(0, totalSupplied * 0.69 - totalBorrowed), [totalSupplied, totalBorrowed], @@ -44,15 +151,9 @@ const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { const collateralRequired = useMemo(() => { if (sliderValue <= 0) return 0; - // supply = borrow * 100 / (LTV * exchangeRate). soUSD ↔ USD is ~1:1 - // for the in-modal estimate. return (sliderValue * 100) / Number(SO_USD_LTV); }, [sliderValue]); - useEffect(() => { - if (!open) setSliderValue(0); - }, [open]); - const submitting = bridgeStatus === Status.PENDING; const amountValid = sliderValue > 0 && sliderValue <= maxBorrowAmount; @@ -67,7 +168,7 @@ const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { 'Borrowed against soUSD on Fuse and sent via Stargate. Funds arrive on Base in ~1–5 min.', props: { badgeText: 'Success' }, }); - onClose(); + onSuccess(); } catch (err) { Toast.show({ type: 'error', @@ -79,89 +180,76 @@ const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { }; return ( - { - if (!value && !submitting) onClose(); - }} - trigger={null} - title="Deposit to Agent Wallet" - containerClassName="min-h-[42rem] overflow-y-auto flex-1" - contentKey="agent-deposit" - > - - - - + + + + - - - Borrow rate - - {positionLoading ? ( - - ) : ( - - {formatNumber(borrowAPY, 2)}% - - )} - - - - - - Collateral Required + + + Borrow rate + + {positionLoading ? ( + + ) : ( + + {formatNumber(borrowAPY, 2)}% - - - {!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. - + + + + + 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. + + + + + + + ); }; diff --git a/components/Agent/IntegrationSnippet.tsx b/components/Agent/IntegrationSnippet.tsx index e56c8acbf..6b0426d7a 100644 --- a/components/Agent/IntegrationSnippet.tsx +++ b/components/Agent/IntegrationSnippet.tsx @@ -2,10 +2,11 @@ import { View } from 'react-native'; import CopyToClipboard from '@/components/CopyToClipboard'; import { Text } from '@/components/ui/text'; -import { AGENT_INTEGRATION_CURL } from '@/constants/agentPromptTemplate'; +import { buildAgentIntegrationCurl } from '@/constants/agentPromptTemplate'; +import { EXPO_PUBLIC_FLASH_API_BASE_URL } from '@/lib/config'; const IntegrationSnippet = () => { - const snippet = AGENT_INTEGRATION_CURL(); + const snippet = buildAgentIntegrationCurl({ baseUrl: EXPO_PUBLIC_FLASH_API_BASE_URL }); return ( diff --git a/constants/agentPromptTemplate.ts b/constants/agentPromptTemplate.ts index 9270e6307..35216cf79 100644 --- a/constants/agentPromptTemplate.ts +++ b/constants/agentPromptTemplate.ts @@ -1,59 +1,77 @@ /** - * Markdown system-prompt template that users can paste into Claude Desktop, + * 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 x402 endpoint. + * how to call the Solid Agent Wallet x402 endpoint and what to do on the + * very first turn. */ -export const AGENT_PROMPT_TEMPLATE = `# Solid Agent Wallet — payment instructions +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 to allowlisted recipients via the Solid Agent Wallet API. Use this when the user asks you to pay for a paid resource that supports the x402 payment standard. +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. -## Authentication +## 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 -Send every request with: +\`POST ${endpoint}\` + +## Authentication \`\`\` -Authorization: Bearer YOUR_SOLID_API_KEY +Authorization: Bearer SOLID_API_KEY Content-Type: application/json \`\`\` -## Endpoint - -\`POST https://api.solid.xyz/v1/agents/me/x402-pay\` - ## 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. Must be in the user's allowlist. | +| \`recipient\` | string | EVM address of the merchant on Base. | | \`description\` | string? | Optional human-readable note. | -## Constraints - -- Recipient must already be on the user's allowlist. If you get a 403, ask the user to add the address from the Agent page first. -- Per-transaction cap and daily cap are enforced server-side. Daily resets at UTC midnight. -- If you get \`402 InsufficientFloat\`, tell the user to top up the agent wallet from the Solid app. +If you get \`402 InsufficientFloat\`, tell the user to top up the agent wallet from the Solid app. ## Example \`\`\`bash -curl -X POST https://api.solid.xyz/v1/agents/me/x402-pay \\ - -H "Authorization: Bearer YOUR_SOLID_API_KEY" \\ +curl -X POST ${endpoint} \\ + -H "Authorization: Bearer $SOLID_API_KEY" \\ -H "Content-Type: application/json" \\ -d '{ - "resourceUrl": "https://api.example.com/paid-resource", + "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. +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. Instead, write a short 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 AGENT_INTEGRATION_CURL = (apiKeyHint = 'YOUR_SOLID_API_KEY') => - `curl -X POST https://api.solid.xyz/v1/agents/me/x402-pay \\ - -H "Authorization: Bearer ${apiKeyHint}" \\ +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://api.example.com/paid","amountUsdc":"100000","recipient":"0x..."}'`; + -d '{"resourceUrl":"https://example.com/paid","amountUsdc":"100000","recipient":"0x..."}'`; +}; From 435b422163f711b0279319139577929ffba0fffd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 07:18:15 +0000 Subject: [PATCH 21/24] feat(agent): QR-only external deposit, non-prod borrow warning, ask-for-key first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External deposit: - Drop the thirdweb connect-wallet flow. The new screen reuses DepositPublicAddress to render the agent EOA as a copyable string + QR code, scoped to Base USDC. Works on both web and native. - Removes the ~200-line transfer/activity-creation code and the thirdweb dependency for this surface. Borrow form: - Yellow warning banner above the slider when isProduction is false: the soUSD/Aave market isn't deployed in QA so the borrow flow can't actually execute. Points users at the external-wallet option for testing. Prompt template: - New first-turn step zero: if the AI doesn't have SOLID_API_KEY yet, it must ask the user to paste one (and tell them where to generate it — the Agent tab) before doing anything else. (cherry picked from commit 500ee5e95a2976fc4981f92cc5085a16849040b9) --- components/Agent/AgentDepositExternalForm.tsx | 275 +++--------------- components/Agent/AgentDepositModal.tsx | 37 ++- constants/agentPromptTemplate.ts | 6 +- 3 files changed, 74 insertions(+), 244 deletions(-) diff --git a/components/Agent/AgentDepositExternalForm.tsx b/components/Agent/AgentDepositExternalForm.tsx index 2e7a43c84..25983166a 100644 --- a/components/Agent/AgentDepositExternalForm.tsx +++ b/components/Agent/AgentDepositExternalForm.tsx @@ -1,246 +1,63 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { ActivityIndicator, TextInput, View } from 'react-native'; -import Toast from 'react-native-toast-message'; +import { Linking, Pressable, View } from 'react-native'; import { Image } from 'expo-image'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Wallet as WalletIcon } from 'lucide-react-native'; -import { useActiveAccount, useSwitchActiveWalletChain } from 'thirdweb/react'; -import { Address, encodeFunctionData, erc20Abi, formatUnits, parseUnits } from 'viem'; -import { useReadContract } from 'wagmi'; -import { z } from 'zod'; +import { ChevronRight, Info } from 'lucide-react-native'; -import ConnectedWalletDropdown from '@/components/ConnectedWalletDropdown'; -import Max from '@/components/Max'; -import { Button } from '@/components/ui/button'; -import Skeleton from '@/components/ui/skeleton'; +import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress'; import { Text } from '@/components/ui/text'; -import { useActivityActions } from '@/hooks/useActivityActions'; -import { getAsset } from '@/lib/assets'; -import { ADDRESSES } from '@/lib/config'; -import { getChain } from '@/lib/thirdweb'; -import { Status, TransactionStatus, TransactionType } from '@/lib/types'; -import { cn, formatNumber } from '@/lib/utils'; -const BASE_CHAIN_ID = 8453; -const BASE_USDC_ADDRESS = ADDRESSES.base.usdc as Address; - -type FormData = { amount: string }; +const SUPPORTED_NETWORKS_URL = + 'https://support.solid.xyz/en/articles/14431132-supported-networks-and-tokens-on-solid'; +const baseIcon = require('@/assets/images/base.png'); type Props = { agentEoaAddress: string; - onSuccess: () => void; }; -const AgentDepositExternalForm = ({ agentEoaAddress, onSuccess }: Props) => { - const account = useActiveAccount(); - const switchChain = useSwitchActiveWalletChain(); - const { createActivity, updateActivity } = useActivityActions(); - const [sendStatus, setSendStatus] = useState(Status.IDLE); - - const eoaAddress = account?.address as Address | undefined; - const { data: eoaTokenBalance, isLoading: isEOABalanceLoading } = useReadContract({ - abi: erc20Abi, - address: BASE_USDC_ADDRESS, - functionName: 'balanceOf', - args: [eoaAddress as Address], - chainId: BASE_CHAIN_ID, - query: { enabled: !!eoaAddress }, - }); - - const formattedBalance = eoaTokenBalance ? formatUnits(eoaTokenBalance, 6) : '0'; - - const schema = useMemo(() => { - const balanceAmount = Number(formattedBalance); - return z.object({ - amount: z - .string() - .refine(val => val !== '' && !isNaN(Number(val)), { error: 'Enter a valid amount' }) - .refine(val => Number(val) > 0, { error: 'Amount must be greater than 0' }) - .refine(val => Number(val) <= balanceAmount, { - error: `Available balance is ${formatNumber(balanceAmount)} USDC`, - }), - }); - }, [formattedBalance]); - - const { control, handleSubmit, formState, watch, reset, setValue, trigger } = useForm({ - resolver: zodResolver(schema) as any, - mode: 'onChange', - defaultValues: { amount: '' }, - }); - - const watchedAmount = watch('amount'); - - // Reset form on unmount. - useEffect(() => () => reset(), [reset]); - - const onSubmit = useCallback( - async (data: FormData) => { - if (!account) { - Toast.show({ - type: 'error', - text1: 'Wallet not connected', - text2: 'Please connect your wallet to continue', - }); - return; - } - try { - setSendStatus(Status.PENDING); - - const baseChain = getChain(BASE_CHAIN_ID); - if (baseChain) { - try { - await switchChain(baseChain); - } catch (chainError) { - console.error('Failed to switch to Base:', chainError); - Toast.show({ - type: 'error', - text1: 'Network switch failed', - text2: 'Please manually switch your wallet to Base mainnet.', - }); - setSendStatus(Status.ERROR); - return; - } - } - - const amountWei = parseUnits(data.amount, 6); - const clientTxId = await createActivity({ - type: TransactionType.AGENT_WALLET_DEPOSIT, - title: 'Deposit to Agent Wallet', - shortTitle: 'Agent Deposit', - amount: data.amount, - symbol: 'USDC', - chainId: BASE_CHAIN_ID, - fromAddress: account.address, - toAddress: agentEoaAddress, - status: TransactionStatus.PENDING, - metadata: { - description: `Deposit ${data.amount} USDC to agent wallet from external wallet`, - tokenAddress: BASE_USDC_ADDRESS, - }, - }); - - const tx = await account.sendTransaction({ - chainId: BASE_CHAIN_ID, - to: BASE_USDC_ADDRESS, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [agentEoaAddress as Address, amountWei], - }), - value: 0n, - }); - - await updateActivity(clientTxId, { - status: TransactionStatus.SUCCESS, - hash: tx.transactionHash, - url: `https://basescan.org/tx/${tx.transactionHash}`, - metadata: { txHash: tx.transactionHash }, - }); - - setSendStatus(Status.SUCCESS); - Toast.show({ - type: 'success', - text1: 'Deposit sent', - text2: 'USDC transferred to your agent wallet on Base.', - props: { badgeText: 'Success' }, - }); - reset(); - onSuccess(); - } catch (error) { - setSendStatus(Status.ERROR); - console.error('External agent deposit error:', error); - Toast.show({ - type: 'error', - text1: 'Deposit failed', - text2: 'Please try again or check your wallet balance.', - }); - } - }, - [account, agentEoaAddress, switchChain, createActivity, updateActivity, reset, onSuccess], - ); - - const submitting = sendStatus === Status.PENDING; - const disabled = submitting || !formState.isValid || !watchedAmount || !account; - +const AgentDepositExternalForm = ({ agentEoaAddress }: Props) => { return ( - - - From wallet - - - - - Deposit amount - - ( - + + + - )} - /> - - USDC - USDC - - - {formState.errors.amount && ( - {formState.errors.amount.message} - )} - - - {isEOABalanceLoading ? ( - - ) : ( - - {formatNumber(Number(formattedBalance))} USDC + Base + + + Send only USDC on Base to this address. Other tokens or chains may result in permanent + loss of funds. - )} - { - setValue('amount', formattedBalance); - trigger('amount'); - }} - /> - - - Sends USDC directly on Base. Funds arrive at your agent wallet immediately and do not earn - yield — borrow against savings instead if you want to keep yield on the principal. + Linking.openURL(SUPPORTED_NETWORKS_URL)} + className="web:hover:opacity-50" + > + + See supported networks + + + + + } + /> + + + + + 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. - - - - ); }; diff --git a/components/Agent/AgentDepositModal.tsx b/components/Agent/AgentDepositModal.tsx index f36e0fa6c..670a2844f 100644 --- a/components/Agent/AgentDepositModal.tsx +++ b/components/Agent/AgentDepositModal.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ActivityIndicator, Linking, Platform, View } from 'react-native'; +import { ActivityIndicator, Linking, View } from 'react-native'; import Toast from 'react-native-toast-message'; import { ChevronRight, Wallet } from 'lucide-react-native'; @@ -12,6 +12,7 @@ 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'; @@ -33,19 +34,15 @@ type Props = { }; const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { - // External-wallet path is web-only — thirdweb's connector flow doesn't run - // inside the React Native runtime. Mobile users keep the borrow path. - const externalEnabled = Platform.OS === 'web'; - - const [step, setStep] = useState(externalEnabled ? 'options' : 'borrow'); - const [previousStep, setPreviousStep] = useState(step); + const [step, setStep] = useState('options'); + const [previousStep, setPreviousStep] = useState('options'); useEffect(() => { if (!open) { - setStep(externalEnabled ? 'options' : 'borrow'); - setPreviousStep(externalEnabled ? 'options' : 'borrow'); + setStep('options'); + setPreviousStep('options'); } - }, [open, externalEnabled]); + }, [open]); const goTo = useCallback( (next: Step) => { @@ -61,7 +58,7 @@ const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { return 'Deposit from external wallet'; }, [step]); - const showBack = step !== 'options' && externalEnabled; + const showBack = step !== 'options'; return ( { showBackButton={showBack} onBackPress={() => goTo('options')} > - {step === 'options' && externalEnabled ? ( + {step === 'options' ? ( ) : step === 'external' && agentEoaAddress ? ( - + ) : ( )} @@ -98,7 +95,7 @@ const DepositOptions = ({ onSelect }: { onSelect: (step: Step) => void }) => ( /> onSelect('external')} /> @@ -181,6 +178,18 @@ const AgentDepositBorrowForm = ({ agentEoaAddress, onSuccess }: BorrowFormProps) 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. + + + )} + Date: Sun, 3 May 2026 12:16:18 +0000 Subject: [PATCH 22/24] feat(agent): inline source dropdown in deposit modal, drop options screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the card deposit modal's source selector. The agent deposit modal is now a single screen with a 'From' dropdown at the top (Borrow against Savings / External Wallet) and the matching form rendered inline below. Removes the prior options-step indirection, the back-button bookkeeping, and the per-option descriptions. - Web: real DropdownMenu (radix) — same -mt-4 rounded-b-2xl pattern. - Native: inline expanding section (Pressable + isOpen) — same as the card flow's SourceSelectorNative. - AgentDepositBorrowForm extracted into its own file for symmetry with AgentDepositExternalForm. (cherry picked from commit 106e1caaeeda7a92a08b228ad3ac2fbfc8e61dc9) --- components/Agent/AgentDepositBorrowForm.tsx | 154 +++++++++ components/Agent/AgentDepositModal.tsx | 359 ++++++++------------ 2 files changed, 296 insertions(+), 217 deletions(-) create mode 100644 components/Agent/AgentDepositBorrowForm.tsx diff --git a/components/Agent/AgentDepositBorrowForm.tsx b/components/Agent/AgentDepositBorrowForm.tsx new file mode 100644 index 000000000..c1c9bd38b --- /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/AgentDepositModal.tsx b/components/Agent/AgentDepositModal.tsx index 670a2844f..45b08b966 100644 --- a/components/Agent/AgentDepositModal.tsx +++ b/components/Agent/AgentDepositModal.tsx @@ -1,32 +1,23 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ActivityIndicator, Linking, View } from 'react-native'; -import Toast from 'react-native-toast-message'; -import { ChevronRight, Wallet } from 'lucide-react-native'; +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 { BorrowSlider } from '@/components/Card/BorrowSlider'; import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; -import TokenDetails from '@/components/TokenCard/TokenDetails'; -import { Button } from '@/components/ui/button'; -import Skeleton from '@/components/ui/skeleton'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; 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 Step = 'options' | 'borrow' | 'external'; - -const STEP_NUMBER: Record = { options: 1, borrow: 2, external: 2 }; -const stepState = (step: Step): ModalState => ({ - name: `agent-deposit-${step}`, - number: STEP_NUMBER[step], -}); +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; @@ -34,230 +25,164 @@ type Props = { }; const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { - const [step, setStep] = useState('options'); - const [previousStep, setPreviousStep] = useState('options'); + const [source, setSource] = useState('borrow'); useEffect(() => { - if (!open) { - setStep('options'); - setPreviousStep('options'); - } + if (!open) setSource('borrow'); }, [open]); - const goTo = useCallback( - (next: Step) => { - setPreviousStep(step); - setStep(next); - }, - [step], - ); - - const title = useMemo(() => { - if (step === 'options') return 'Deposit to Agent Wallet'; - if (step === 'borrow') return 'Borrow against savings'; - return 'Deposit from external wallet'; - }, [step]); - - const showBack = step !== 'options'; - return ( { if (!value) onClose(); }} trigger={null} - title={title} - contentKey={`agent-deposit-${step}`} + title="Deposit to Agent Wallet" + contentKey="agent-deposit" containerClassName="min-h-[42rem] overflow-y-auto flex-1" - showBackButton={showBack} - onBackPress={() => goTo('options')} > - {step === 'options' ? ( - - ) : step === 'external' && agentEoaAddress ? ( - - ) : ( - - )} + + + From + + + + {source === 'external' && agentEoaAddress ? ( + + ) : ( + + )} + ); }; -const DepositOptions = ({ onSelect }: { onSelect: (step: Step) => void }) => ( - - onSelect('borrow')} - /> - onSelect('external')} - /> - -); - -const OptionItem = ({ - title, - description, - onPress, -}: { - title: string; - description: string; - onPress: () => void; -}) => ( - -); - -type BorrowFormProps = { - agentEoaAddress?: string; - onSuccess: () => void; +const SOURCE_LABEL: Record = { + borrow: 'Borrow against Savings', + external: 'External Wallet', }; -const AgentDepositBorrowForm = ({ agentEoaAddress, onSuccess }: BorrowFormProps) => { - const [sliderValue, setSliderValue] = useState(0); - const { - totalSupplied, - totalBorrowed, - borrowAPY, - isLoading: positionLoading, - } = useAaveBorrowPosition(); - const { borrowAndDeposit, bridgeStatus } = useBorrowAndDepositToAgent(agentEoaAddress); +const SOURCE_TOKEN: Record = { + borrow: '', + external: 'USDC', +}; - const maxBorrowAmount = useMemo( - () => Math.max(0, totalSupplied * 0.69 - totalBorrowed), - [totalSupplied, totalBorrowed], +const SourceIcon = ({ value }: { value: AgentDepositSource }) => + value === 'borrow' ? ( + + ) : ( + ); - 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' }, - }); - } - }; +const SourceSelector = ({ + value, + onChange, +}: { + value: AgentDepositSource; + onChange: (next: AgentDepositSource) => void; +}) => + Platform.OS === 'web' ? ( + + ) : ( + + ); - 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. - +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], + ); - - - Borrow rate - - {positionLoading ? ( - - ) : ( - - {formatNumber(borrowAPY, 2)}% - - )} - + return ( + + setIsOpen(open => !open)} + > + + + {SOURCE_LABEL[value]} - - - - Collateral Required - - - - {!sliderValue ? ( - - ) : ( - - {formatNumber(collateralRequired)} soUSD - - )} - + + {SOURCE_TOKEN[value] ? ( + {SOURCE_TOKEN[value]} + ) : null} + - - - 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. - - + + {isOpen && ( + + select('borrow')} + > + + Borrow against Savings + + select('external')} + > + + External Wallet + - - - + )} ); }; From 83d6eb68740ce9bbf02822374ddb77517bd380e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 12:43:31 +0000 Subject: [PATCH 23/24] chore(agent): drop 'See supported networks' link from external deposit Single-network surface (Base USDC only); the link sent users to a generic supported-networks doc that doesn't apply here. The Base chain badge + 'Send only USDC on Base' warning carry the right scope. (cherry picked from commit a4dcd98c806667dff5a34e62273528c137e969b8) --- components/Agent/AgentDepositExternalForm.tsx | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/components/Agent/AgentDepositExternalForm.tsx b/components/Agent/AgentDepositExternalForm.tsx index 25983166a..cc93ddee3 100644 --- a/components/Agent/AgentDepositExternalForm.tsx +++ b/components/Agent/AgentDepositExternalForm.tsx @@ -1,12 +1,10 @@ -import { Linking, Pressable, View } from 'react-native'; +import { View } from 'react-native'; import { Image } from 'expo-image'; -import { ChevronRight, Info } from 'lucide-react-native'; +import { Info } from 'lucide-react-native'; import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress'; import { Text } from '@/components/ui/text'; -const SUPPORTED_NETWORKS_URL = - 'https://support.solid.xyz/en/articles/14431132-supported-networks-and-tokens-on-solid'; const baseIcon = require('@/assets/images/base.png'); type Props = { @@ -38,15 +36,6 @@ const AgentDepositExternalForm = ({ agentEoaAddress }: Props) => { Send only USDC on Base to this address. Other tokens or chains may result in permanent loss of funds. - Linking.openURL(SUPPORTED_NETWORKS_URL)} - className="web:hover:opacity-50" - > - - See supported networks - - - } /> From fc0760c0b97983c578e974b4a9294290c36e0077 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 11:47:32 +0000 Subject: [PATCH 24/24] feat(agent): hide Agent tab in production until release --- hooks/useNav.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/hooks/useNav.ts b/hooks/useNav.ts index 73e7b9bd9..976e1c11e 100644 --- a/hooks/useNav.ts +++ b/hooks/useNav.ts @@ -38,7 +38,15 @@ const useNav = () => { label: isProduction ? 'Points' : 'Rewards', href: isProduction ? path.POINTS : path.REWARDS, }; - const menuItems: MenuItem[] = [home, savings, card, points, agent, 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, };