-
-
-
+
+
+
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
+ }
+ },
}
)
)