Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -40,7 +41,9 @@ export function GroupedAssetRow({ group }: GroupedAssetRowProps) {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm md:text-base text-foreground">{primaryAsset.symbol}</span>
<Amount.Percent value={primaryAsset.priceChange24h} showSign autoColor className="text-xs" />
{!isStablecoin(primaryAsset.symbol) && (
<Amount.Percent value={primaryAsset.priceChange24h} showSign autoColor className="text-xs" />
)}
</div>
<div className="text-sm text-muted-foreground truncate">
<Amount.Crypto value={totalCryptoBalancePrecision} symbol={primaryAsset.symbol} decimals={6} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -19,7 +20,9 @@ export function PortfolioAssetRow({ asset, showNetwork }: PortfolioAssetRowProps
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm md:text-base text-foreground">{asset.symbol}</span>
<Amount.Percent value={asset.priceChange24h} showSign autoColor className="text-xs" />
{!isStablecoin(asset.symbol) && (
<Amount.Percent value={asset.priceChange24h} showSign autoColor className="text-xs" />
)}
</div>
<div className="text-sm text-muted-foreground truncate">
<Amount.Crypto value={asset.cryptoBalancePrecision} symbol={asset.symbol} decimals={6} />
Expand Down
11 changes: 5 additions & 6 deletions apps/agentic-chat/src/components/tools/GetAssetsUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 (
<div className="flex flex-col">
<span className="text-xl font-bold">{isLoading ? <Skeleton className="h-6 w-20" /> : value}</span>
<span className="text-xl font-bold">{value}</span>
<span className="text-xs text-muted-foreground font-normal">{label}</span>
</div>
)
Expand Down Expand Up @@ -104,9 +103,9 @@ export function GetAssetsUI({ toolPart }: ToolUIComponentProps<'getAssetsTool'>)
<ToolCard.Content>
<ToolCard.Details>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<StatMetric label="Volume" value={formatFiat(asset.volume24h)} isLoading={!asset.volume24h} />
<StatMetric label="Market Cap" value={formatFiat(asset.marketCap)} isLoading={!asset.marketCap} />
<StatMetric label="FDV" value={formatFiat(asset.fdv)} isLoading={!asset.fdv} />
<StatMetric label="Volume" value={formatFiat(asset.volume24h)} />
<StatMetric label="Market Cap" value={formatFiat(asset.marketCap)} />
<StatMetric label="FDV" value={formatFiat(asset.fdv)} />
</div>

<div className="border-t border-border pt-4 mt-4">
Expand Down
22 changes: 22 additions & 0 deletions apps/agentic-chat/src/lib/isStablecoin.ts
Original file line number Diff line number Diff line change
@@ -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())
}
10 changes: 7 additions & 3 deletions apps/agentic-chat/src/lib/safe/executeSafeTransaction.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -49,13 +50,16 @@ export function executeSafeBatchTransaction(
provider: SafeProvider
): Promise<string> {
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(
Expand All @@ -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
})
Expand Down
1 change: 1 addition & 0 deletions apps/agentic-chat/src/lib/safe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
24 changes: 18 additions & 6 deletions apps/agentic-chat/src/lib/safe/safeFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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),
Expand All @@ -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}`)

Expand Down
8 changes: 6 additions & 2 deletions apps/agentic-chat/src/lib/safe/safeModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -107,13 +108,16 @@ export async function enableComposableCowModules(
signerAddress: string,
provider: SafeProvider
): Promise<string> {
// 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(
Expand Down
62 changes: 62 additions & 0 deletions apps/agentic-chat/src/lib/safe/types.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> }

// 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<typeof publicClient.request>[0])
}
return walletProvider.request(args)
},
}
}
3 changes: 3 additions & 0 deletions apps/agentic-chat/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading