diff --git a/apps/agentic-chat/src/components/Portfolio/GroupedAssetRow.tsx b/apps/agentic-chat/src/components/Portfolio/GroupedAssetRow.tsx index 7e0384c8..18c9c36f 100644 --- a/apps/agentic-chat/src/components/Portfolio/GroupedAssetRow.tsx +++ b/apps/agentic-chat/src/components/Portfolio/GroupedAssetRow.tsx @@ -1,6 +1,7 @@ import { Amount } from '@/components/ui/Amount' import { AssetIcon } from '@/components/ui/AssetIcon' import { DrawerListItem } from '@/components/ui/DrawerListItem' +import { isStablecoin } from '@/lib/isStablecoin' import type { GroupedPortfolioAsset, PortfolioAsset } from '@/types/portfolio' import { PortfolioAssetRow } from './PortfolioAssetRow' @@ -40,7 +41,9 @@ export function GroupedAssetRow({ group }: GroupedAssetRowProps) {
{primaryAsset.symbol} - + {!isStablecoin(primaryAsset.symbol) && ( + + )}
diff --git a/apps/agentic-chat/src/components/Portfolio/PortfolioAssetRow.tsx b/apps/agentic-chat/src/components/Portfolio/PortfolioAssetRow.tsx index 2b25efe8..546c5b14 100644 --- a/apps/agentic-chat/src/components/Portfolio/PortfolioAssetRow.tsx +++ b/apps/agentic-chat/src/components/Portfolio/PortfolioAssetRow.tsx @@ -1,5 +1,6 @@ import { Amount } from '@/components/ui/Amount' import { AssetIcon } from '@/components/ui/AssetIcon' +import { isStablecoin } from '@/lib/isStablecoin' import type { PortfolioAsset } from '@/types/portfolio' type PortfolioAssetRowProps = { @@ -19,7 +20,9 @@ export function PortfolioAssetRow({ asset, showNetwork }: PortfolioAssetRowProps
{asset.symbol} - + {!isStablecoin(asset.symbol) && ( + + )}
diff --git a/apps/agentic-chat/src/components/tools/GetAssetsUI.tsx b/apps/agentic-chat/src/components/tools/GetAssetsUI.tsx index b3a1e661..421b4b92 100644 --- a/apps/agentic-chat/src/components/tools/GetAssetsUI.tsx +++ b/apps/agentic-chat/src/components/tools/GetAssetsUI.tsx @@ -6,7 +6,6 @@ import { formatCompactNumber, formatFiat } from '@/lib/number' import { Amount } from '../ui/Amount' import { AssetIcon } from '../ui/AssetIcon' -import { Skeleton } from '../ui/Skeleton' import { ToolCard } from '../ui/ToolCard' import { useToolStateRender } from './toolUIHelpers' @@ -29,10 +28,10 @@ function formatPriceChange(value: number | null): { return { text, color, icon } } -function StatMetric({ label, value, isLoading }: { label: string; value: React.ReactNode; isLoading?: boolean }) { +function StatMetric({ label, value }: { label: string; value: React.ReactNode }) { return (
- {isLoading ? : value} + {value} {label}
) @@ -104,9 +103,9 @@ export function GetAssetsUI({ toolPart }: ToolUIComponentProps<'getAssetsTool'>)
- - - + + +
diff --git a/apps/agentic-chat/src/lib/isStablecoin.ts b/apps/agentic-chat/src/lib/isStablecoin.ts new file mode 100644 index 00000000..fa351b37 --- /dev/null +++ b/apps/agentic-chat/src/lib/isStablecoin.ts @@ -0,0 +1,22 @@ +const STABLECOIN_SYMBOLS = new Set([ + 'USDC', + 'USDT', + 'DAI', + 'XDAI', + 'FRAX', + 'LUSD', + 'GUSD', + 'USDP', + 'TUSD', + 'BUSD', + 'PYUSD', + 'USDS', + 'USDE', + 'GHO', + 'CRVUSD', + 'EUSD', +]) + +export function isStablecoin(symbol: string): boolean { + return STABLECOIN_SYMBOLS.has(symbol.toUpperCase()) +} diff --git a/apps/agentic-chat/src/lib/safe/executeSafeTransaction.ts b/apps/agentic-chat/src/lib/safe/executeSafeTransaction.ts index 67421603..9bd7e21e 100644 --- a/apps/agentic-chat/src/lib/safe/executeSafeTransaction.ts +++ b/apps/agentic-chat/src/lib/safe/executeSafeTransaction.ts @@ -1,6 +1,7 @@ import Safe from '@safe-global/protocol-kit' import { createPublicClient, custom } from 'viem' +import { createSafeProvider } from './types' import type { SafeProvider } from './types' // Wait for a tx to be mined so the Safe nonce increments on-chain before @@ -49,13 +50,16 @@ export function executeSafeBatchTransaction( provider: SafeProvider ): Promise { return enqueue(safeAddress, chainId, async () => { + // Use composite provider: reads via public RPC, writes via wallet (WalletConnect) + const compositeProvider = createSafeProvider(chainId, provider) + const protocolKit = await Safe.init({ - provider, + provider: compositeProvider, signer: signerAddress, safeAddress, }) - const publicClient = createPublicClient({ transport: custom(provider) }) + const publicClient = createPublicClient({ transport: custom(compositeProvider) }) const connectedChainId = await publicClient.getChainId() if (connectedChainId !== chainId) { throw new Error( @@ -75,7 +79,7 @@ export function executeSafeBatchTransaction( const result = await protocolKit.executeTransaction(signedTx) const txHash = typeof result === 'string' ? result : result.hash - await waitForTxConfirmation(txHash, provider) + await waitForTxConfirmation(txHash, compositeProvider) return txHash }) diff --git a/apps/agentic-chat/src/lib/safe/index.ts b/apps/agentic-chat/src/lib/safe/index.ts index 3598cd8f..ebd8f5b0 100644 --- a/apps/agentic-chat/src/lib/safe/index.ts +++ b/apps/agentic-chat/src/lib/safe/index.ts @@ -7,4 +7,5 @@ export { } from './safeModules' export { ensureSafeReady } from './ensureSafeReady' export { executeSafeTransaction, executeSafeBatchTransaction } from './executeSafeTransaction' +export { createSafeProvider } from './types' export type { SafeProvider } from './types' diff --git a/apps/agentic-chat/src/lib/safe/safeFactory.ts b/apps/agentic-chat/src/lib/safe/safeFactory.ts index a1c23854..90c7b978 100644 --- a/apps/agentic-chat/src/lib/safe/safeFactory.ts +++ b/apps/agentic-chat/src/lib/safe/safeFactory.ts @@ -7,6 +7,7 @@ import { wagmiConfig } from '@/lib/wagmi-config' import { useSafeStore } from '@/stores/safeStore' import { checkDomainVerifier, checkFallbackHandler } from './safeModules' +import { createSafeProvider } from './types' import type { SafeProvider } from './types' // Pinned so SDK default changes can't silently break existing Safe addresses @@ -61,8 +62,11 @@ export async function deploySafe( ) } + // Use composite provider: reads via public RPC, writes via wallet (WalletConnect) + const compositeProvider = createSafeProvider(chainId, provider) + const protocolKit = await Safe.init({ - provider, + provider: compositeProvider, signer: signerAddress, predictedSafe: { safeAccountConfig: { @@ -94,6 +98,18 @@ export async function deploySafe( // Deploy the Safe const deploymentTransaction = await protocolKit.createSafeDeploymentTransaction() + const publicClient = createPublicClient({ transport: custom(compositeProvider) }) + + // Estimate gas with 20% buffer — Safe deployments on L2s like Arbitrum need + // more gas than wallets typically estimate from the deployment calldata alone + const estimatedGas = await publicClient.estimateGas({ + account: signerAddress as `0x${string}`, + to: deploymentTransaction.to as `0x${string}`, + data: deploymentTransaction.data as `0x${string}`, + value: BigInt(deploymentTransaction.value), + }) + const gas = estimatedGas + (estimatedGas * 20n) / 100n + // Send the deployment transaction via viem wallet client const walletClient = createWalletClient({ transport: custom(provider), @@ -105,13 +121,9 @@ export async function deploySafe( to: deploymentTransaction.to as `0x${string}`, data: deploymentTransaction.data as `0x${string}`, value: BigInt(deploymentTransaction.value), + gas, chain: null, }) - - // Wait for deployment to be confirmed on-chain before marking as deployed. - // Without this, subsequent steps (like token deposits to the Safe) would - // prompt the wallet before the Safe contract exists, triggering warnings. - const publicClient = createPublicClient({ transport: custom(provider) }) const deployReceipt = await publicClient.waitForTransactionReceipt({ hash: txHash, confirmations: 1 }) if (deployReceipt.status === 'reverted') throw new Error(`Safe deployment transaction reverted: ${txHash}`) diff --git a/apps/agentic-chat/src/lib/safe/safeModules.ts b/apps/agentic-chat/src/lib/safe/safeModules.ts index 7b3948e4..31e2f195 100644 --- a/apps/agentic-chat/src/lib/safe/safeModules.ts +++ b/apps/agentic-chat/src/lib/safe/safeModules.ts @@ -4,6 +4,7 @@ import { createPublicClient, custom, domainSeparator, encodeFunctionData, getAdd import { useSafeStore } from '@/stores/safeStore' import { executeSafeBatchTransaction } from './executeSafeTransaction' +import { createSafeProvider } from './types' import type { SafeProvider } from './types' // ExtensibleFallbackHandler — required for ComposableCoW ERC-1271 verification @@ -107,13 +108,16 @@ export async function enableComposableCowModules( signerAddress: string, provider: SafeProvider ): Promise { + // Use composite provider: reads via public RPC, writes via wallet (WalletConnect) + const compositeProvider = createSafeProvider(chainId, provider) + const protocolKit = await Safe.init({ - provider, + provider: compositeProvider, signer: signerAddress, safeAddress, }) - const publicClient = createPublicClient({ transport: custom(provider) }) + const publicClient = createPublicClient({ transport: custom(compositeProvider) }) const connectedChainId = await publicClient.getChainId() if (connectedChainId !== chainId) { throw new Error( diff --git a/apps/agentic-chat/src/lib/safe/types.ts b/apps/agentic-chat/src/lib/safe/types.ts index e6c74f4e..8f80671a 100644 --- a/apps/agentic-chat/src/lib/safe/types.ts +++ b/apps/agentic-chat/src/lib/safe/types.ts @@ -1,4 +1,66 @@ +import { createPublicClient, http, fallback } from 'viem' +import * as allChains from 'viem/chains' + +import { SUPPORTED_EVM_CHAINS } from '@/lib/chains' + // Matches @safe-global/protocol-kit's Eip1193Provider. Uses `any` for the request // args so viem WalletClient (which uses narrow method unions) is assignable without casts. // eslint-disable-next-line @typescript-eslint/no-explicit-any export type SafeProvider = { request: (args: any) => Promise } + +// Read-only RPC methods that the Safe SDK calls during init/execution. +// These are routed to a public RPC because WalletConnect wallets often +// don't relay read calls properly (e.g. "Missing or invalid parameters"). +// Everything NOT in this set goes to the wallet provider (signing, sending, +// eth_chainId for wallet-chain verification, wallet_* methods, etc.). +const PUBLIC_RPC_METHODS = new Set([ + 'eth_call', + 'eth_getCode', + 'eth_getStorageAt', + 'eth_getBalance', + 'eth_getTransactionCount', + 'eth_getTransactionReceipt', + 'eth_getTransactionByHash', + 'eth_getBlockByNumber', + 'eth_getBlockByHash', + 'eth_blockNumber', + 'eth_estimateGas', + 'eth_getLogs', +]) + +// Build a public client with a fallback transport: tries the app's configured +// RPC first, then the chain's default public RPC (e.g. rpc.gnosischain.com). +function getPublicClientWithFallback(chainId: number) { + const chainConfig = SUPPORTED_EVM_CHAINS.find(c => c.chain.id === chainId) + const viemChain = Object.values(allChains).find(c => typeof c === 'object' && 'id' in c && c.id === chainId) + const chain = chainConfig?.chain ?? viemChain + if (!chain) return undefined + + const configuredUrl = chainConfig?.rpcUrl + const defaultRpcUrl = viemChain && 'rpcUrls' in viemChain ? viemChain.rpcUrls?.default?.http?.[0] : undefined + const transports = configuredUrl ? [http(configuredUrl)] : [] + if (defaultRpcUrl && defaultRpcUrl !== configuredUrl) { + transports.push(http(defaultRpcUrl)) + } + if (transports.length === 0) transports.push(http()) + + return createPublicClient({ chain, transport: fallback(transports) }) +} + +// Creates a composite provider that routes read-only RPC calls through a +// public RPC (with fallback) and everything else through the wallet provider. +// This avoids issues where WalletConnect doesn't properly relay read-only +// calls like eth_call or eth_getCode. +export function createSafeProvider(chainId: number, walletProvider: SafeProvider): SafeProvider { + const publicClient = getPublicClientWithFallback(chainId) + if (!publicClient) return walletProvider + + return { + request: async (args: { method: string; params?: unknown[] }) => { + if (PUBLIC_RPC_METHODS.has(args.method)) { + return publicClient.request(args as Parameters[0]) + } + return walletProvider.request(args) + }, + } +} diff --git a/apps/agentic-chat/src/main.tsx b/apps/agentic-chat/src/main.tsx index 112fadb2..70e4feaf 100644 --- a/apps/agentic-chat/src/main.tsx +++ b/apps/agentic-chat/src/main.tsx @@ -44,6 +44,9 @@ if (analyticsEnabled) { }) } +// Request persistent storage to prevent browser eviction of IndexedDB data +void navigator.storage?.persist?.() + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) root.render( diff --git a/apps/agentic-chat/src/stores/chatStore.ts b/apps/agentic-chat/src/stores/chatStore.ts index 16910fb9..8f05c591 100644 --- a/apps/agentic-chat/src/stores/chatStore.ts +++ b/apps/agentic-chat/src/stores/chatStore.ts @@ -30,10 +30,46 @@ export interface KnownTransaction { network?: string } +const CONVERSATIONS_BACKUP_KEY = 'shapeshift-chat-conversations-backup' + +let hydrationVerified = false + const idbStorage: StateStorage = { - getItem: async name => (await idbGet(name)) ?? null, - setItem: async (name, value) => await idbSet(name, value), - removeItem: async name => await idbDel(name), + getItem: async name => { + try { + return (await idbGet(name)) ?? null + } catch (e) { + console.error('[chatStore] IDB getItem failed:', e) + return null + } + }, + setItem: async (name, value) => { + if (!hydrationVerified) { + console.warn('[chatStore] Blocked IDB write before hydration verified') + return + } + try { + await idbSet(name, value) + try { + const parsed = JSON.parse(value) + const conversations = parsed?.state?.conversations + if (Array.isArray(conversations) && conversations.length > 0) { + localStorage.setItem(CONVERSATIONS_BACKUP_KEY, JSON.stringify(conversations)) + } + } catch { + // parsing failure is non-critical + } + } catch (e) { + console.error('[chatStore] IDB setItem failed:', e) + } + }, + removeItem: async name => { + try { + await idbDel(name) + } catch (e) { + console.error('[chatStore] IDB removeItem failed:', e) + } + }, } export const STORE_VERSION = 4 @@ -105,13 +141,19 @@ export const useChatStore = create()( }, deleteConversation: (id: string) => { - set(state => ({ - conversations: state.conversations.filter(c => c.id !== id), - persistedTransactions: state.persistedTransactions.filter(tx => tx.conversationId !== id), - messagesByConversation: Object.fromEntries( - Object.entries(state.messagesByConversation).filter(([key]) => key !== id) - ), - })) + set(state => { + const conversations = state.conversations.filter(c => c.id !== id) + if (conversations.length === 0) { + localStorage.removeItem(CONVERSATIONS_BACKUP_KEY) + } + return { + conversations, + persistedTransactions: state.persistedTransactions.filter(tx => tx.conversationId !== id), + messagesByConversation: Object.fromEntries( + Object.entries(state.messagesByConversation).filter(([key]) => key !== id) + ), + } + }) }, setMessages: (conversationId: string, messages: ChatMessage[]) => { @@ -378,6 +420,36 @@ export const useChatStore = create()( } return state as unknown as ChatState }, + onRehydrateStorage: () => { + return (state, error) => { + if (error) { + console.error('[chatStore] Hydration failed:', error) + return + } + if (state && state.conversations.length > 0) { + hydrationVerified = true + return + } + // Hydrated empty — try restoring from localStorage backup + const backup = localStorage.getItem(CONVERSATIONS_BACKUP_KEY) + if (backup && state) { + try { + const conversations = JSON.parse(backup) + if (Array.isArray(conversations) && conversations.length > 0) { + console.warn( + '[chatStore] IDB empty but backup found — restoring', + conversations.length, + 'conversations' + ) + state.conversations = conversations + } + } catch { + console.error('[chatStore] Failed to parse conversations backup') + } + } + hydrationVerified = true + } + }, } ) )