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
59 changes: 59 additions & 0 deletions .tickets/sa-hhve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
id: sa-hhve
status: in_progress
deps: []
links: []
created: 2026-03-17T07:51:16Z
type: bug
priority: 1
assignee: Jibles
---
# TWAP fails on cross-chain first use when Safe exists on different chain

## Objective

When a user has a Safe deployed on Ethereum and tries to create a TWAP order on Arbitrum for the first time, the server-side PREPARE step fails with a Safe address mismatch error before the client-side SAFE_CHECK step (which would auto-deploy the Safe) ever runs. The Safe auto-deployment is never reached because validation fails first.

## Context & Findings

**Root cause (confirmed):** `getSafeAddressForChain()` in `walletContextSimple.ts:81` falls back to `walletContext.safeAddress` when no per-chain entry exists in `safeDeploymentState`. This fallback is the Ethereum Safe address. `verifySafeOwnership()` then predicts the Safe address for Arbitrum using the Safe SDK — but the SDK uses a **different singleton contract** on L2 chains (GnosisSafeL2 at `0x3E5c...`) vs L1 (GnosisSafe at `0xd9Db...`). Different singleton → different proxy init code → different CREATE2 address → mismatch → error.

**Data flow trace:**

1. `discoverSafeOnChain()` (`safeFactory.ts:134-185`) only stores entries for chains where the Safe IS deployed. Undeployed chains get no entry.
2. `useSafeAccount.safeAddress` (`useSafeAccount.ts:90-93`) picks the first stored address (from Ethereum).
3. `ChatProvider.tsx:69-70` sends both `safeAddress` (Ethereum) and `safeDeploymentState` (only Ethereum entry) to the server.
4. Server `getSafeAddressForChain(ctx, 42161)` at `walletContextSimple.ts:81`: `safeDeploymentState[42161]` is undefined → falls back to `safeAddress` (Ethereum address `0xA`).
5. `verifySafeOwnership("0xA", owner, 42161)` at `safeAddressVerification.ts:20-29`: Safe not deployed on Arb (code === '0x') → predicts address on Arb → gets `0xB` (different due to L2 singleton) → throws.
6. The TWAP stepper Step 2 (SAFE_CHECK in `useConditionalOrderExecution.tsx:158-159`) which calls `ensureSafeReady()` → `deploySafe()` never executes because Step 0 (PREPARE) already failed.

**Reproduction:** Connect wallet with an Ethereum-only Safe. Request a TWAP order on Arbitrum. Observe the "Safe vault address doesn't match" error.

**Rejected approaches:**
- Always using L1 singleton on all chains — would break existing L2 Safes and lose L2-specific event emission
- Skipping verification for undeployed Safes — weakens the security check that prevents address spoofing

## Files

- `apps/agentic-server/src/utils/walletContextSimple.ts` — `getSafeAddressForChain()` line 81: the fallback logic that uses wrong cross-chain address. When no per-chain entry exists, should predict the address for the target chain using `predictSafeAddress(ownerAddress, chainId)` instead of falling back to the top-level `safeAddress`
- `apps/agentic-server/src/utils/safeAddressVerification.ts` — `verifySafeOwnership()`: the verification that catches the mismatch (no change needed here, it's working correctly)
- `apps/agentic-server/src/utils/predictSafeAddress.ts` — server-side prediction function, already exists and can be reused in the fix
- `apps/agentic-chat/src/lib/safe/safeFactory.ts` — client-side `discoverSafeOnChain()` line 163: only stores deployed chains (reference, may not need change)
- `apps/agentic-chat/src/hooks/useSafeAccount.ts` — `safeAddress` derivation at line 90-93 (reference)

## Gotchas

- The server `predictSafeAddress` takes `(ownerAddress, chainId)` — need to extract `ownerAddress` from `walletContext.connectedWallets` in `getSafeAddressForChain` to call it
- `getSafeAddressForChain` is currently async (returns Promise) so adding prediction doesn't change the interface
- The same L1/L2 singleton issue affects stop-loss orders too — they use the same `getSafeAddressForChain` fallback path
- The fix must preserve the security verification: predicted address should still go through `verifySafeOwnership` (which will pass since the predicted address matches itself)
- `predictSafeAddress` has a cache, so repeated calls for the same owner+chain are cheap

## Acceptance Criteria

- [ ] getSafeAddressForChain uses predictSafeAddress(ownerAddress, chainId) instead of walletContext.safeAddress when no per-chain entry exists
- [ ] TWAP creation on Arbitrum works when user only has Ethereum Safe deployed
- [ ] Stop-loss creation on a new chain also works (same code path)
- [ ] Security verification still runs on the predicted address
- [ ] Existing flows (Safe already deployed on target chain) are unaffected
- [ ] Lint and type-check pass
17 changes: 17 additions & 0 deletions .tickets/sa-qczu.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,20 @@ When executing multiple swaps in parallel (e.g., 3x PEPE→ETH on Arbitrum), the
- [ ] Allowance is sufficient for each swap at execution time
- [ ] Quote expiry is validated before submitting swap transaction
- [ ] No "Execution reverted" or "insufficient allowance" errors for queued swaps

## Notes

**2026-03-14T01:02:13Z**

## Investigation Findings (2026-03-14)

Root cause analysis in the ticket is confirmed accurate against the code.

Key findings:
- The architectural split (server: eager quote+allowance, client: locked execution) is the fundamental issue
- BebopQuote.expiry exists in the type but is never validated or surfaced
- getAllowance is called at quote time, not execution time
- Exact-amount approvals (not MaxUint256) compound the race condition
- walletMutex itself works correctly; it's just in the wrong layer

Simplest fix path: re-fetch quote + re-check allowance inside withWalletLock, or move the lock boundary to encompass quote fetching.
1 change: 1 addition & 0 deletions apps/agentic-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
"idb-keyval": "^6.2.2",
"immer": "^10.1.3",
"katex": "^0.16.22",
"lucide-react": "^0.488.0",
Expand Down
61 changes: 49 additions & 12 deletions apps/agentic-chat/src/components/tools/GetTransactionHistoryUI.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import type { ParsedTransaction, Network } from '@shapeshiftoss/types'
import { networkToChainIdMap, networkToNativeSymbol } from '@shapeshiftoss/types'
import { NETWORK_ICONS } from '@shapeshiftoss/utils'
import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, CheckCircle2, FileCode, XCircle } from 'lucide-react'
import {
ArrowDownLeft,
ArrowLeftRight,
ArrowUpRight,
CheckCircle2,
FileCode,
ScrollText,
ShieldCheck,
Vault,
XCircle,
} from 'lucide-react'
import type React from 'react'

import { bnOrZero } from '@/lib/bignumber'
Expand All @@ -24,6 +34,25 @@ const TRANSACTION_ICONS: Record<ParsedTransaction['type'], React.ReactElement> =
receive: <ArrowDownLeft className="w-5 h-5 text-green-500" />,
swap: <ArrowLeftRight className="w-5 h-5 text-blue-500" />,
contract: <FileCode className="w-5 h-5 text-purple-500" />,
limitOrder: <ScrollText className="w-5 h-5 text-blue-500" />,
stopLoss: <ScrollText className="w-5 h-5 text-blue-500" />,
twap: <ScrollText className="w-5 h-5 text-blue-500" />,
deposit: <Vault className="w-5 h-5 text-orange-500" />,
withdraw: <Vault className="w-5 h-5 text-green-500" />,
approval: <ShieldCheck className="w-5 h-5 text-purple-500" />,
}

const TRANSACTION_LABELS: Record<ParsedTransaction['type'], string> = {
send: 'Send',
receive: 'Receive',
swap: 'Swap',
contract: 'Contract interaction',
limitOrder: 'Limit Order',
stopLoss: 'Stop-Loss Order',
twap: 'TWAP Order',
deposit: 'Deposit',
withdraw: 'Withdraw',
approval: 'Approval',
}

function getNativeSymbol(network?: string): string {
Expand Down Expand Up @@ -57,11 +86,14 @@ function TransactionCard({
networkIcon?: string
}) {
const swapTokens = getSwapTokens(tx)
const isSwap = tx.type === 'swap'
const isSwapLike = ['swap', 'limitOrder', 'stopLoss', 'twap'].includes(tx.type)
const isSendLike = ['send', 'deposit'].includes(tx.type)
const isReceiveLike = ['receive', 'withdraw'].includes(tx.type)
const isContractLike = ['contract', 'approval'].includes(tx.type)
const isSuccess = tx.status === 'success'
const explorerUrl = getExplorerUrl(network, tx.txid)

const label = tx.type === 'contract' ? 'Contract interaction' : tx.type.charAt(0).toUpperCase() + tx.type.slice(1)
const label = TRANSACTION_LABELS[tx.type]

return (
<ToolCard.Root defaultOpen={false}>
Expand Down Expand Up @@ -91,7 +123,7 @@ function TransactionCard({
<TxAmount variant="positive">{formatTokenAmount(swapTokens.tokenIn)}</TxAmount>
</>
)}
{!swapTokens && tx.type === 'send' && (
{!swapTokens && isSendLike && (
<>
<TxSecondaryText>{truncateAddress(tx.to)}</TxSecondaryText>
<TxAmount variant="negative">
Expand All @@ -105,7 +137,7 @@ function TransactionCard({
</TxAmount>
</>
)}
{!swapTokens && tx.type === 'receive' && (
{!swapTokens && isReceiveLike && (
<>
<TxSecondaryText>{truncateAddress(tx.from)}</TxSecondaryText>
<TxAmount variant="positive">
Expand All @@ -119,7 +151,17 @@ function TransactionCard({
</TxAmount>
</>
)}
{!swapTokens && tx.type === 'contract' && (
{!swapTokens && isSwapLike && (
<TxAmount variant="default">
{tx.tokenTransfers?.[0]
? formatTokenAmount(tx.tokenTransfers[0])
: formatCryptoAmount(tx.value, {
symbol: getNativeSymbol(tx.network),
decimals: MAX_DISPLAYED_DECIMALS,
})}
</TxAmount>
)}
{!swapTokens && isContractLike && (
<TxAmount variant="negative">
{tx.tokenTransfers?.[0]
? formatTokenAmount(tx.tokenTransfers[0])
Expand Down Expand Up @@ -178,7 +220,7 @@ function TransactionCard({
value={<Amount.Crypto value={tx.fee} symbol={getNativeSymbol(network)} />}
/>
<ToolCard.DetailItem label="Date" value={formatTimestamp(tx.timestamp)} />
{!isSwap && (
{!isSwapLike && (
<>
<ToolCard.DetailItem label="From" value={truncateAddress(tx.from)} />
<ToolCard.DetailItem label="To" value={truncateAddress(tx.to)} />
Expand Down Expand Up @@ -215,11 +257,6 @@ export function GetTransactionHistoryUI({ toolPart }: ToolUIComponentProps<'tran

const renderTransactions = input?.renderTransactions

// Early exit if explicitly told not to render
if (renderTransactions === false) {
return null
}

// Determine how many to render: specific number or all
const renderCount = typeof renderTransactions === 'number' ? renderTransactions : transactions.length

Expand Down
5 changes: 4 additions & 1 deletion apps/agentic-chat/src/lib/transactionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ export type SwapTokens = {
tokenIn: TokenTransfer
}

const SWAP_LIKE_TYPES = ['swap', 'limitOrder', 'stopLoss', 'twap'] as const

export function getSwapTokens(tx: ParsedTransaction): SwapTokens | null {
if (tx.type !== 'swap' || !tx.tokenTransfers || tx.tokenTransfers.length < 2) return null
if (!(SWAP_LIKE_TYPES as readonly string[]).includes(tx.type) || !tx.tokenTransfers || tx.tokenTransfers.length < 2)
return null

const [tokenOut, tokenIn] = tx.tokenTransfers
if (!tokenOut || !tokenIn) return null
Expand Down
3 changes: 3 additions & 0 deletions apps/agentic-chat/src/providers/ChatProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,16 @@ export function ChatProvider({ children }: ChatProviderProps) {
const registryOrders =
safeAddresses.length > 0 ? useOrderStore.getState().getAllOrderSummaries(safeAddresses) : []

const knownTransactions = useChatStore.getState().getKnownTransactions()

return {
evmAddress: wallet.evmAddress,
solanaAddress: wallet.solanaAddress,
approvedChainIds: wallet.approvedChainIds,
safeAddress: wallet.safeAddress,
safeDeploymentState: wallet.safeDeploymentState,
registryOrders,
knownTransactions: knownTransactions.length > 0 ? knownTransactions : undefined,
}
},
}),
Expand Down
Loading
Loading