diff --git a/.tickets/sa-hhve.md b/.tickets/sa-hhve.md new file mode 100644 index 00000000..a87d0658 --- /dev/null +++ b/.tickets/sa-hhve.md @@ -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 diff --git a/.tickets/sa-qczu.md b/.tickets/sa-qczu.md index e5eac379..f21ae82a 100644 --- a/.tickets/sa-qczu.md +++ b/.tickets/sa-qczu.md @@ -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. diff --git a/apps/agentic-chat/package.json b/apps/agentic-chat/package.json index 3c561bf4..90dd628b 100644 --- a/apps/agentic-chat/package.json +++ b/apps/agentic-chat/package.json @@ -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", diff --git a/apps/agentic-chat/src/components/tools/GetTransactionHistoryUI.tsx b/apps/agentic-chat/src/components/tools/GetTransactionHistoryUI.tsx index f4809948..125ab177 100644 --- a/apps/agentic-chat/src/components/tools/GetTransactionHistoryUI.tsx +++ b/apps/agentic-chat/src/components/tools/GetTransactionHistoryUI.tsx @@ -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' @@ -24,6 +34,25 @@ const TRANSACTION_ICONS: Record = receive: , swap: , contract: , + limitOrder: , + stopLoss: , + twap: , + deposit: , + withdraw: , + approval: , +} + +const TRANSACTION_LABELS: Record = { + 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 { @@ -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 ( @@ -91,7 +123,7 @@ function TransactionCard({ {formatTokenAmount(swapTokens.tokenIn)} )} - {!swapTokens && tx.type === 'send' && ( + {!swapTokens && isSendLike && ( <> {truncateAddress(tx.to)} @@ -105,7 +137,7 @@ function TransactionCard({ )} - {!swapTokens && tx.type === 'receive' && ( + {!swapTokens && isReceiveLike && ( <> {truncateAddress(tx.from)} @@ -119,7 +151,17 @@ function TransactionCard({ )} - {!swapTokens && tx.type === 'contract' && ( + {!swapTokens && isSwapLike && ( + + {tx.tokenTransfers?.[0] + ? formatTokenAmount(tx.tokenTransfers[0]) + : formatCryptoAmount(tx.value, { + symbol: getNativeSymbol(tx.network), + decimals: MAX_DISPLAYED_DECIMALS, + })} + + )} + {!swapTokens && isContractLike && ( {tx.tokenTransfers?.[0] ? formatTokenAmount(tx.tokenTransfers[0]) @@ -178,7 +220,7 @@ function TransactionCard({ value={} /> - {!isSwap && ( + {!isSwapLike && ( <> @@ -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 diff --git a/apps/agentic-chat/src/lib/transactionUtils.ts b/apps/agentic-chat/src/lib/transactionUtils.ts index b53a9b60..480a18b4 100644 --- a/apps/agentic-chat/src/lib/transactionUtils.ts +++ b/apps/agentic-chat/src/lib/transactionUtils.ts @@ -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 diff --git a/apps/agentic-chat/src/providers/ChatProvider.tsx b/apps/agentic-chat/src/providers/ChatProvider.tsx index 741e686e..1e295b47 100644 --- a/apps/agentic-chat/src/providers/ChatProvider.tsx +++ b/apps/agentic-chat/src/providers/ChatProvider.tsx @@ -60,6 +60,8 @@ 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, @@ -67,6 +69,7 @@ export function ChatProvider({ children }: ChatProviderProps) { safeAddress: wallet.safeAddress, safeDeploymentState: wallet.safeDeploymentState, registryOrders, + knownTransactions: knownTransactions.length > 0 ? knownTransactions : undefined, } }, }), diff --git a/apps/agentic-chat/src/stores/chatStore.ts b/apps/agentic-chat/src/stores/chatStore.ts index 54b217e8..16910fb9 100644 --- a/apps/agentic-chat/src/stores/chatStore.ts +++ b/apps/agentic-chat/src/stores/chatStore.ts @@ -1,14 +1,42 @@ import type { useChat } from '@ai-sdk/react' +import { get as idbGet, set as idbSet, del as idbDel } from 'idb-keyval' import { produce, enableMapSet } from 'immer' import { create } from 'zustand' +import type { StateStorage } from 'zustand/middleware' import { persist, createJSONStorage } from 'zustand/middleware' -import type { ToolExecutionState } from '@/lib/executionState' +import type { + ChainResult, + ConditionalOrderMeta, + LimitOrderMeta, + SendMeta, + SwapMeta, + ToolExecutionState, + VaultDepositMeta, + VaultWithdrawAllMeta, +} from '@/lib/executionState' import type { Conversation } from '@/types' enableMapSet() -export const STORE_VERSION = 3 +// Mirrors KnownTransaction from agentic-server/src/utils/walletContextSimple.ts +export interface KnownTransaction { + txHash: string + type: 'swap' | 'send' | 'limitOrder' | 'stopLoss' | 'twap' | 'deposit' | 'withdraw' | 'approval' + sellSymbol?: string + sellAmount?: string + buySymbol?: string + buyAmount?: string + network?: string +} + +const idbStorage: StateStorage = { + getItem: async name => (await idbGet(name)) ?? null, + setItem: async (name, value) => await idbSet(name, value), + removeItem: async name => await idbDel(name), +} + +export const STORE_VERSION = 4 export const MAX_MESSAGES_PER_CONVERSATION = 500 type ChatMessage = ReturnType['messages'][number] @@ -43,6 +71,7 @@ interface ChatState { setRuntimeState: (toolCallId: string, updater: (draft: T) => void) => void persistTransaction: (state: ToolExecutionState) => void getPersistedTransaction: (toolCallId: string) => ToolExecutionState | undefined + getKnownTransactions: () => KnownTransaction[] } export const useChatStore = create()( @@ -177,11 +206,163 @@ export const useChatStore = create()( getPersistedTransaction: (toolCallId: string) => { return get().persistedTransactions.find(tx => tx.toolCallId === toolCallId) }, + + getKnownTransactions: (): KnownTransaction[] => { + const TOOL_NAMES = [ + 'initiateSwapTool', + 'initiateSwapUsdTool', + 'sendTool', + 'createLimitOrderTool', + 'createStopLossTool', + 'createTwapTool', + 'vaultDepositTool', + 'vaultWithdrawTool', + 'vaultWithdrawAllTool', + ] as const + + type SellBuy = { + sell?: { symbol?: string; amount?: string } + buy?: { symbol?: string; amount?: string } + } + + const conditionalOrderTxs = ( + meta: { approvalTxHash?: string; depositTxHash?: string; txHash?: string; networkName?: string }, + { sell, buy }: SellBuy, + mainType: KnownTransaction['type'] + ): KnownTransaction[] => { + const results: KnownTransaction[] = [] + if (meta.approvalTxHash) + results.push({ + txHash: meta.approvalTxHash, + type: 'approval', + sellSymbol: sell?.symbol, + network: meta.networkName, + }) + if (meta.depositTxHash) + results.push({ + txHash: meta.depositTxHash, + type: 'deposit', + sellSymbol: sell?.symbol, + sellAmount: sell?.amount, + network: meta.networkName, + }) + if (meta.txHash) + results.push({ + txHash: meta.txHash, + type: mainType, + sellSymbol: sell?.symbol, + sellAmount: sell?.amount, + buySymbol: buy?.symbol, + buyAmount: buy?.amount, + network: meta.networkName, + }) + return results + } + + return get() + .persistedTransactions.filter( + tx => tx.terminal && !tx.error && TOOL_NAMES.includes(tx.toolName as (typeof TOOL_NAMES)[number]) + ) + .flatMap((tx): KnownTransaction[] => { + const output = tx.toolOutput as Record | undefined + const summary = output?.summary as Record | undefined + + if (tx.toolName === 'sendTool') { + const meta = tx.meta as SendMeta + if (!meta.txHash) return [] + const asset = summary as { symbol?: string; amount?: string } | undefined + return [ + { + txHash: meta.txHash, + type: 'send', + sellSymbol: asset?.symbol, + sellAmount: asset?.amount, + network: meta.networkName, + }, + ] + } + + if (tx.toolName === 'initiateSwapTool' || tx.toolName === 'initiateSwapUsdTool') { + const meta = tx.meta as SwapMeta + if (!meta.txHash) return [] + const sell = summary?.sellAsset as { symbol?: string; amount?: string; network?: string } | undefined + const buy = summary?.buyAsset as { symbol?: string; estimatedAmount?: string } | undefined + return [ + { + txHash: meta.txHash, + type: 'swap', + sellSymbol: sell?.symbol, + sellAmount: sell?.amount, + buySymbol: buy?.symbol, + buyAmount: buy?.estimatedAmount, + network: meta.networkName ?? sell?.network, + }, + ] + } + + if (tx.toolName === 'createLimitOrderTool') { + const meta = tx.meta as LimitOrderMeta + const sell = summary?.sellAsset as { symbol?: string; amount?: string } | undefined + const buy = summary?.buyAsset as { symbol?: string; estimatedAmount?: string } | undefined + return conditionalOrderTxs( + meta, + { sell, buy: buy && { ...buy, amount: buy.estimatedAmount } }, + 'limitOrder' + ) + } + + if (tx.toolName === 'createStopLossTool') { + const meta = tx.meta as ConditionalOrderMeta + const sell = summary?.sellAsset as { symbol?: string; amount?: string } | undefined + const buy = summary?.buyAsset as { symbol?: string; estimatedAmount?: string } | undefined + return conditionalOrderTxs( + meta, + { sell, buy: buy && { ...buy, amount: buy.estimatedAmount } }, + 'stopLoss' + ) + } + + if (tx.toolName === 'createTwapTool') { + const meta = tx.meta as ConditionalOrderMeta + const sell = summary?.sellAsset as { symbol?: string; totalAmount?: string } | undefined + const buy = summary?.buyAsset as { symbol?: string } | undefined + return conditionalOrderTxs( + meta, + { sell: sell && { symbol: sell.symbol, amount: sell.totalAmount }, buy }, + 'twap' + ) + } + + if (tx.toolName === 'vaultDepositTool' || tx.toolName === 'vaultWithdrawTool') { + const meta = tx.meta as VaultDepositMeta + if (!meta.txHash) return [] + const asset = summary?.asset as { symbol?: string; amount?: string } | undefined + return [ + { + txHash: meta.txHash, + type: tx.toolName === 'vaultDepositTool' ? 'deposit' : 'withdraw', + sellSymbol: asset?.symbol, + sellAmount: asset?.amount, + network: meta.networkName, + }, + ] + } + + if (tx.toolName === 'vaultWithdrawAllTool') { + const meta = tx.meta as VaultWithdrawAllMeta + return meta.chainResults + .filter((cr: ChainResult) => cr.txHash && !cr.error) + .map((cr: ChainResult) => ({ txHash: cr.txHash!, type: 'withdraw' as const, network: cr.network })) + } + + return [] + }) + }, }), { name: 'shapeshift-chat-store', version: STORE_VERSION, - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(() => idbStorage), partialize: state => ({ conversations: state.conversations, persistedTransactions: state.persistedTransactions, diff --git a/apps/agentic-server/src/lib/transactionHistory/__tests__/enrichment.test.ts b/apps/agentic-server/src/lib/transactionHistory/__tests__/enrichment.test.ts new file mode 100644 index 00000000..a8a87b5d --- /dev/null +++ b/apps/agentic-server/src/lib/transactionHistory/__tests__/enrichment.test.ts @@ -0,0 +1,176 @@ +import type { ParsedTransaction } from '@shapeshiftoss/types' +import { describe, expect, test } from 'bun:test' + +import type { KnownTransaction } from '../../../utils/walletContextSimple' +import { enrichTransactions } from '../enrichment' + +function makeTx(overrides: Record = {}): ParsedTransaction { + return { + txid: '0xabc123', + timestamp: 1704067200, + blockHeight: 100, + status: 'success', + type: 'contract', + value: '0', + fee: '0.001', + from: '0xUser', + to: '0xRouter', + ...overrides, + } as ParsedTransaction +} + +function enrichOne(tx: ParsedTransaction, known?: KnownTransaction[]): ParsedTransaction { + const result = enrichTransactions([tx], known) + return result[0]! +} + +describe('enrichTransactions', () => { + test('enriches contract tx to swap when matching known swap exists', () => { + const known: KnownTransaction[] = [ + { + txHash: '0xabc123', + type: 'swap', + sellSymbol: 'ETH', + sellAmount: '1.5', + buySymbol: 'USDC', + buyAmount: '3000', + network: 'ethereum', + }, + ] + + const result = enrichOne(makeTx({ txid: '0xABC123', type: 'contract' }), known) + expect(result.type).toBe('swap') + expect(result.tokenTransfers).toHaveLength(2) + expect(result.tokenTransfers![0]!.symbol).toBe('ETH') + expect(result.tokenTransfers![0]!.amount).toBe('-1.5') + expect(result.tokenTransfers![1]!.symbol).toBe('USDC') + expect(result.tokenTransfers![1]!.amount).toBe('3000') + }) + + test('enriches contract tx to send when matching known send exists', () => { + const known: KnownTransaction[] = [ + { txHash: '0xDEF456', type: 'send', sellSymbol: 'USDC', sellAmount: '100', network: 'ethereum' }, + ] + + const result = enrichOne(makeTx({ txid: '0xdef456', type: 'contract' }), known) + expect(result.type).toBe('send') + }) + + test('does not override already-correct swap tx', () => { + const existingTransfers = [ + { symbol: 'WETH', amount: '-0.5', decimals: 18, from: '0xUser', to: '0xRouter', assetId: 'eip155:1/erc20:0x' }, + ] + const known: KnownTransaction[] = [ + { txHash: '0xabc123', type: 'swap', sellSymbol: 'ETH', sellAmount: '1', buySymbol: 'USDC', buyAmount: '2000' }, + ] + + const result = enrichOne(makeTx({ txid: '0xabc123', type: 'swap', tokenTransfers: existingTransfers }), known) + expect(result.type).toBe('swap') + expect(result.tokenTransfers).toBe(existingTransfers) + }) + + test('does not modify tx with no matching known transaction', () => { + const known: KnownTransaction[] = [ + { txHash: '0xother', type: 'swap', sellSymbol: 'ETH', sellAmount: '1', buySymbol: 'USDC', buyAmount: '2000' }, + ] + + const result = enrichOne(makeTx({ txid: '0xunknown', type: 'contract' }), known) + expect(result.type).toBe('contract') + }) + + test('returns transactions unchanged when knownTransactions is undefined', () => { + const result = enrichOne(makeTx({ type: 'contract' }), undefined) + expect(result.type).toBe('contract') + }) + + test('returns transactions unchanged when knownTransactions is empty', () => { + const result = enrichOne(makeTx({ type: 'contract' }), []) + expect(result.type).toBe('contract') + }) + + test('handles case-insensitive txHash matching', () => { + const known: KnownTransaction[] = [{ txHash: '0xaabbcc', type: 'send', sellSymbol: 'ETH', sellAmount: '1' }] + + const result = enrichOne(makeTx({ txid: '0xAaBbCc', type: 'contract' }), known) + expect(result.type).toBe('send') + }) + + test('reclassifies swap type with partial info (sell only) and attaches sell transfer', () => { + const known: KnownTransaction[] = [{ txHash: '0xabc', type: 'swap', sellSymbol: 'ETH', sellAmount: '1' }] + + const result = enrichOne(makeTx({ txid: '0xabc', type: 'contract' }), known) + expect(result.type).toBe('swap') + expect(result.tokenTransfers).toHaveLength(1) + expect(result.tokenTransfers![0]!.symbol).toBe('ETH') + expect(result.tokenTransfers![0]!.amount).toBe('-1') + }) + + test('enriches contract tx to limitOrder with token transfers', () => { + const known: KnownTransaction[] = [ + { + txHash: '0xlimit1', + type: 'limitOrder', + sellSymbol: 'WETH', + sellAmount: '2', + buySymbol: 'DAI', + buyAmount: '5000', + }, + ] + + const result = enrichOne(makeTx({ txid: '0xLIMIT1', type: 'contract' }), known) + expect(result.type).toBe('limitOrder') + expect(result.tokenTransfers).toHaveLength(2) + expect(result.tokenTransfers![0]!.symbol).toBe('WETH') + expect(result.tokenTransfers![1]!.symbol).toBe('DAI') + }) + + test('enriches contract tx to stopLoss with token transfers', () => { + const known: KnownTransaction[] = [ + { txHash: '0xsl1', type: 'stopLoss', sellSymbol: 'ETH', sellAmount: '10', buySymbol: 'USDC', buyAmount: '20000' }, + ] + + const result = enrichOne(makeTx({ txid: '0xsl1', type: 'contract' }), known) + expect(result.type).toBe('stopLoss') + expect(result.tokenTransfers).toHaveLength(2) + }) + + test('enriches contract tx to twap with token transfers', () => { + const known: KnownTransaction[] = [ + { txHash: '0xtwap1', type: 'twap', sellSymbol: 'WETH', sellAmount: '5', buySymbol: 'USDC', buyAmount: '10000' }, + ] + + const result = enrichOne(makeTx({ txid: '0xtwap1', type: 'contract' }), known) + expect(result.type).toBe('twap') + expect(result.tokenTransfers).toHaveLength(2) + }) + + test('enriches contract tx to deposit', () => { + const known: KnownTransaction[] = [{ txHash: '0xdep1', type: 'deposit', sellSymbol: 'USDC', sellAmount: '1000' }] + + const result = enrichOne(makeTx({ txid: '0xdep1', type: 'contract' }), known) + expect(result.type).toBe('deposit') + }) + + test('enriches contract tx to withdraw', () => { + const known: KnownTransaction[] = [{ txHash: '0xwith1', type: 'withdraw', sellSymbol: 'USDC', sellAmount: '500' }] + + const result = enrichOne(makeTx({ txid: '0xwith1', type: 'contract' }), known) + expect(result.type).toBe('withdraw') + }) + + test('enriches contract tx to approval', () => { + const known: KnownTransaction[] = [{ txHash: '0xappr1', type: 'approval', sellSymbol: 'WETH' }] + + const result = enrichOne(makeTx({ txid: '0xappr1', type: 'contract' }), known) + expect(result.type).toBe('approval') + }) + + test('reclassifies order type with partial swap info and attaches sell transfer', () => { + const known: KnownTransaction[] = [{ txHash: '0xlim', type: 'limitOrder', sellSymbol: 'ETH', sellAmount: '1' }] + + const result = enrichOne(makeTx({ txid: '0xlim', type: 'contract' }), known) + expect(result.type).toBe('limitOrder') + expect(result.tokenTransfers).toHaveLength(1) + expect(result.tokenTransfers![0]!.symbol).toBe('ETH') + }) +}) diff --git a/apps/agentic-server/src/lib/transactionHistory/__tests__/evmParser.test.ts b/apps/agentic-server/src/lib/transactionHistory/__tests__/evmParser.test.ts index bf0aa307..0d82cad7 100644 --- a/apps/agentic-server/src/lib/transactionHistory/__tests__/evmParser.test.ts +++ b/apps/agentic-server/src/lib/transactionHistory/__tests__/evmParser.test.ts @@ -431,6 +431,66 @@ describe('parseEvmTransaction', () => { }) }) + describe('internalTxs classification', () => { + test('classifies as swap when user sends token and receives native via internal call', () => { + const tx = makeTx({ + from: USER, + to: ROUTER, + value: '0', + tokenTransfers: [ + makeTokenTransfer({ contract: USDC_CONTRACT, symbol: 'USDC', from: USER, to: ROUTER, value: '2000000000' }), + ], + internalTxs: [{ from: ROUTER, to: USER, value: '1000000000000000000' }], + }) + const result = parseEvmTransaction(tx, USER, 'ethereum') + expect(result.type).toBe('swap') + expect(result.tokenTransfers!.length).toBe(2) + const ethTransfer = result.tokenTransfers!.find(t => t.symbol === 'ETH') + expect(ethTransfer).toBeDefined() + expect(parseFloat(ethTransfer!.amount)).toBeGreaterThan(0) + }) + + test('classifies as swap when user sends native via internal call and receives token', () => { + const tx = makeTx({ + from: USER, + to: ROUTER, + value: '0', + tokenTransfers: [ + makeTokenTransfer({ + contract: DAI_CONTRACT, + symbol: 'DAI', + decimals: 18, + from: ROUTER, + to: USER, + value: '1000000000000000000', + }), + ], + internalTxs: [{ from: USER, to: ROUTER, value: '500000000000000000' }], + }) + const result = parseEvmTransaction(tx, USER, 'ethereum') + expect(result.type).toBe('swap') + const ethTransfer = result.tokenTransfers!.find(t => t.symbol === 'ETH') + expect(ethTransfer).toBeDefined() + expect(parseFloat(ethTransfer!.amount)).toBeLessThan(0) + }) + + test('does not double-count when tx.value already covers native transfer', () => { + const tx = makeTx({ + from: USER, + to: ROUTER, + value: '1000000000000000000', + tokenTransfers: [ + makeTokenTransfer({ contract: USDC_CONTRACT, symbol: 'USDC', from: ROUTER, to: USER, value: '2000000000' }), + ], + internalTxs: [], + }) + const result = parseEvmTransaction(tx, USER, 'ethereum') + expect(result.type).toBe('swap') + const ethTransfers = result.tokenTransfers!.filter(t => t.symbol === 'ETH') + expect(ethTransfers).toHaveLength(1) + }) + }) + describe('decimals fallback', () => { test('uses PRECISION_HIGH (18) when decimals is undefined', () => { const tx = makeTx({ diff --git a/apps/agentic-server/src/lib/transactionHistory/__tests__/query.test.ts b/apps/agentic-server/src/lib/transactionHistory/__tests__/query.test.ts index 7bb9cf9f..bb848538 100644 --- a/apps/agentic-server/src/lib/transactionHistory/__tests__/query.test.ts +++ b/apps/agentic-server/src/lib/transactionHistory/__tests__/query.test.ts @@ -129,9 +129,9 @@ describe('Query Functions', () => { expect(result[1]?.txid).toBe('tx4') // Has 1 transfer }) - test('should return original array if no sortBy', () => { + test('should sort by timestamp desc by default when no sortBy', () => { const result = sort(mockTransactions) - expect(result).toEqual(mockTransactions) + expect(result.map(tx => tx.txid)).toEqual(['tx4', 'tx2', 'tx3', 'tx1']) }) }) diff --git a/apps/agentic-server/src/lib/transactionHistory/enrichment.ts b/apps/agentic-server/src/lib/transactionHistory/enrichment.ts new file mode 100644 index 00000000..8873f31e --- /dev/null +++ b/apps/agentic-server/src/lib/transactionHistory/enrichment.ts @@ -0,0 +1,66 @@ +import type { ParsedTransaction, TokenTransfer } from '@shapeshiftoss/types' + +import type { KnownTransaction } from '../../utils/walletContextSimple' + +export function enrichTransactions( + transactions: ParsedTransaction[], + knownTransactions?: KnownTransaction[] +): ParsedTransaction[] { + if (!knownTransactions || knownTransactions.length === 0) return transactions + + const knownMap = new Map() + for (const kt of knownTransactions) { + knownMap.set(kt.txHash.toLowerCase(), kt) + } + + return transactions.map((tx): ParsedTransaction => { + if (tx.type !== 'contract') return tx + + const known = knownMap.get(tx.txid.toLowerCase()) + if (!known) return tx + + const enriched = { ...tx, type: known.type, value: tx.value ?? '0' } as ParsedTransaction + + if (known.type === 'swap' || known.type === 'limitOrder' || known.type === 'stopLoss' || known.type === 'twap') { + const tokenTransfers = buildSwapTokenTransfers(known) + if (tokenTransfers.length > 0) { + return { ...enriched, tokenTransfers } + } + return enriched + } + + if (known.type === 'send' || known.type === 'deposit' || known.type === 'withdraw' || known.type === 'approval') { + return enriched + } + + return tx + }) +} + +function buildSwapTokenTransfers(known: KnownTransaction): TokenTransfer[] { + const transfers: TokenTransfer[] = [] + + if (known.sellSymbol && known.sellAmount) { + transfers.push({ + symbol: known.sellSymbol, + amount: `-${known.sellAmount}`, + decimals: 0, + from: '', + to: '', + assetId: '', + }) + } + + if (known.buySymbol && known.buyAmount) { + transfers.push({ + symbol: known.buySymbol, + amount: known.buyAmount, + decimals: 0, + from: '', + to: '', + assetId: '', + }) + } + + return transfers +} diff --git a/apps/agentic-server/src/lib/transactionHistory/evmParser.ts b/apps/agentic-server/src/lib/transactionHistory/evmParser.ts index ead9cc5c..47e5bc9d 100644 --- a/apps/agentic-server/src/lib/transactionHistory/evmParser.ts +++ b/apps/agentic-server/src/lib/transactionHistory/evmParser.ts @@ -1,5 +1,5 @@ import type { Network, ParsedTransaction, TokenTransfer } from '@shapeshiftoss/types' -import { networkToChainIdMap, networkToNativeAssetId } from '@shapeshiftoss/types' +import { networkToChainIdMap, networkToNativeAssetId, networkToNativeSymbol } from '@shapeshiftoss/types' import { AssetService, fromBaseUnit } from '@shapeshiftoss/utils' import { EVM_NATIVE_DECIMALS, PRECISION_HIGH } from './constants' @@ -71,6 +71,24 @@ function calculateNetTransfers(tx: EvmTx, userAddress: string): NetTransfer[] { return netTransfers } +function sumInternalNativeTransfers( + tx: EvmTx, + userAddress: string +): { receivedInternal: bigint; sentInternal: bigint } { + let receivedInternal = 0n + let sentInternal = 0n + const normalized = userAddress.toLowerCase() + + for (const itx of tx.internalTxs ?? []) { + const value = BigInt(itx.value) + if (value === 0n) continue + if (itx.to.toLowerCase() === normalized) receivedInternal += value + if (itx.from.toLowerCase() === normalized) sentInternal += value + } + + return { receivedInternal, sentInternal } +} + function determineTransactionType(tx: EvmTx, userAddress: string): ParsedTransaction['type'] { const normalizedUserAddress = userAddress.toLowerCase() const normalizedFrom = tx.from.toLowerCase() @@ -81,9 +99,13 @@ function determineTransactionType(tx: EvmTx, userAddress: string): ParsedTransac const userSentNative = hasNativeValue && normalizedFrom === normalizedUserAddress const userReceivedNative = hasNativeValue && normalizedTo === normalizedUserAddress - if (netTransfers.length > 0 || userSentNative) { - const hasNegative = netTransfers.some(t => t.netAmount < 0n) || userSentNative - const hasPositive = netTransfers.some(t => t.netAmount > 0n) || userReceivedNative + const { receivedInternal, sentInternal } = sumInternalNativeTransfers(tx, userAddress) + const userReceivedNativeInternal = receivedInternal > 0n + const userSentNativeInternal = sentInternal > 0n + + if (netTransfers.length > 0 || userSentNative || userSentNativeInternal) { + const hasNegative = netTransfers.some(t => t.netAmount < 0n) || userSentNative || userSentNativeInternal + const hasPositive = netTransfers.some(t => t.netAmount > 0n) || userReceivedNative || userReceivedNativeInternal if (hasNegative && hasPositive) { return 'swap' @@ -122,7 +144,7 @@ export function parseEvmTransaction(tx: EvmTx, userAddress: string, network: Net let tokenTransfers: TokenTransfer[] | undefined - if (type === 'swap' && netTransfers.length > 0) { + if (type === 'swap' && (netTransfers.length > 0 || tx.internalTxs?.length)) { const sortedTransfers = netTransfers.sort((a, b) => { if (a.netAmount < 0n && b.netAmount > 0n) return -1 if (a.netAmount > 0n && b.netAmount < 0n) return 1 @@ -148,12 +170,15 @@ export function parseEvmTransaction(tx: EvmTx, userAddress: string, network: Net const hasNativeValue = BigInt(tx.value) > 0n && normalizedFrom === normalizedUserAddress const hasNegativeToken = sortedTransfers.some(t => t.netAmount < 0n) + const hasPositiveToken = sortedTransfers.some(t => t.netAmount > 0n) + const nativeAssetId = networkToNativeAssetId[network] + const nativeSymbol = networkToNativeSymbol[network] + const { receivedInternal, sentInternal } = sumInternalNativeTransfers(tx, userAddress) if (hasNativeValue && !hasNegativeToken) { - const nativeAssetId = networkToNativeAssetId[network] tokenTransfers = [ { - symbol: 'ETH', + symbol: nativeSymbol, amount: fromBaseUnit(tx.value, EVM_NATIVE_DECIMALS), decimals: EVM_NATIVE_DECIMALS, from: userAddress, @@ -164,6 +189,36 @@ export function parseEvmTransaction(tx: EvmTx, userAddress: string, network: Net ...tokenTransfers, ] } + + if (receivedInternal > 0n && !hasPositiveToken) { + tokenTransfers = [ + ...(tokenTransfers ?? []), + { + symbol: nativeSymbol, + amount: fromBaseUnit(receivedInternal.toString(), EVM_NATIVE_DECIMALS), + decimals: EVM_NATIVE_DECIMALS, + from: tx.to, + to: userAddress, + assetId: nativeAssetId, + icon: AssetService.getIcon(nativeAssetId), + }, + ] + } + + if (sentInternal > 0n && !hasNegativeToken && !hasNativeValue) { + tokenTransfers = [ + { + symbol: nativeSymbol, + amount: `-${fromBaseUnit(sentInternal.toString(), EVM_NATIVE_DECIMALS)}`, + decimals: EVM_NATIVE_DECIMALS, + from: userAddress, + to: tx.to, + assetId: nativeAssetId, + icon: AssetService.getIcon(nativeAssetId), + }, + ...(tokenTransfers ?? []), + ] + } } else if (tx.tokenTransfers && tx.tokenTransfers.length > 0) { const userInvolvedTransfers = tx.tokenTransfers.filter( transfer => diff --git a/apps/agentic-server/src/lib/transactionHistory/query/sort.ts b/apps/agentic-server/src/lib/transactionHistory/query/sort.ts index 94f40c4f..8d3d6bb6 100644 --- a/apps/agentic-server/src/lib/transactionHistory/query/sort.ts +++ b/apps/agentic-server/src/lib/transactionHistory/query/sort.ts @@ -16,16 +16,13 @@ export const SORT_ORDERS = ['asc', 'desc'] as const export type SortOrder = (typeof SORT_ORDERS)[number] export interface SortOptions { - field: SortField - order: SortOrder + field?: SortField + order?: SortOrder } export function sort(transactions: TransactionWithUsd[], sortBy?: SortOptions): TransactionWithUsd[] { - if (!sortBy) { - return transactions - } - - const { field, order } = sortBy + const field = sortBy?.field ?? 'timestamp' + const order = sortBy?.order ?? 'desc' const sorted = [...transactions].sort((a, b) => { let aVal: number diff --git a/apps/agentic-server/src/lib/transactionHistory/schemas.ts b/apps/agentic-server/src/lib/transactionHistory/schemas.ts index 14c664d5..b4bc3e38 100644 --- a/apps/agentic-server/src/lib/transactionHistory/schemas.ts +++ b/apps/agentic-server/src/lib/transactionHistory/schemas.ts @@ -57,6 +57,15 @@ export const evmTxSchema = z.object({ gasPrice: z.string().optional(), inputData: z.string().optional(), tokenTransfers: z.array(evmTokenTransferSchema).optional(), + internalTxs: z + .array( + z.object({ + from: z.string(), + to: z.string(), + value: z.string(), + }) + ) + .optional(), }) export const solanaTxSchema = z.object({ @@ -73,7 +82,12 @@ export const solanaTxSchema = z.object({ }) export const transactionFilterParams = { - types: z.array(z.enum(TRANSACTION_TYPES)).optional().describe('Filter by transaction types (e.g., ["swap", "send"])'), + types: z + .array(z.enum(TRANSACTION_TYPES)) + .optional() + .describe( + 'REQUIRED for type-specific queries. Map: "swap/trade/exchange" → ["swap"], "send/sent/transfer" → ["send"], "receive/received" → ["receive"]. Only omit for "all transactions" queries.' + ), status: z.array(z.enum(TRANSACTION_STATUSES)).optional().describe('Filter by transaction status'), dateFrom: z.number().optional().describe('Filter transactions from this Unix timestamp (inclusive)'), dateTo: z.number().optional().describe('Filter transactions until this Unix timestamp (inclusive)'), @@ -227,13 +241,13 @@ export const transactionHistoryToolInput = z.object({ .object({ field: z .enum(SORT_FIELDS) - .default('timestamp') + .optional() .describe( 'Field to sort by. timestamp (default), fee, value, blockHeight, tokenTransferCount (number of token transfers), usdValueSent, usdValueReceived, or usdFee' ), order: z .enum(SORT_ORDERS) - .default('desc') + .optional() .describe('Sort order: asc (ascending - oldest/smallest first) or desc (descending - newest/largest first)'), }) .optional() @@ -251,7 +265,7 @@ export const transactionHistoryToolInput = z.object({ offset: z .number() .min(0) - .default(0) + .optional() .describe( 'Number of transactions to skip after filtering and sorting. Useful for pagination or "show me second most expensive swap".' ), @@ -263,16 +277,16 @@ export const transactionHistoryToolInput = z.object({ includeTransactions: z .boolean() - .default(true) - .describe( - 'Include transaction details in response to render as UI cards. Defaults to true. Set to false only when doing pure aggregation queries without displaying transactions.' - ), + .optional() + .describe('Set to false for pure aggregation queries (e.g., "how many swaps?"). Omit otherwise.'), renderTransactions: z - .union([z.boolean(), z.number().int().min(0)]) + .number() + .int() + .min(0) .optional() .describe( - 'Controls which transactions to display as UI cards. IMPORTANT: Set this based on user intent - true/undefined: display all (use for "show all transactions"); false: display none (text-only analysis); number: display first N (use 1 for "last transaction", 3-5 for "recent transactions"). Prevents UI crashes by limiting rendered cards. Only applies when includeTransactions is true.' + 'Number of transaction cards to display in UI. 1 for "last", 3-5 for "recent", 10-20 for browsing. Omit to display all.' ), }) diff --git a/apps/agentic-server/src/routes/chat.ts b/apps/agentic-server/src/routes/chat.ts index f00691dd..d62b6047 100644 --- a/apps/agentic-server/src/routes/chat.ts +++ b/apps/agentic-server/src/routes/chat.ts @@ -40,7 +40,12 @@ import { switchNetworkTool } from '../tools/switchNetwork' import { transactionHistoryTool } from '../tools/transactionHistory' import { createTwapTool, getTwapOrdersTool, cancelTwapTool } from '../tools/twap' import { vaultBalanceTool, vaultDepositTool, vaultWithdrawTool, vaultWithdrawAllTool } from '../tools/vault' -import type { ActiveOrderSummary, SafeChainDeployment, WalletContext } from '../utils/walletContextSimple' +import type { + ActiveOrderSummary, + KnownTransaction, + SafeChainDeployment, + WalletContext, +} from '../utils/walletContextSimple' const allEvmChainIds = [ ethChainId, @@ -86,7 +91,8 @@ function buildWalletContext( approvedChainIds?: string[], safeAddress?: string, safeDeploymentState?: Record, - registryOrders?: ActiveOrderSummary[] + registryOrders?: ActiveOrderSummary[], + knownTransactions?: KnownTransaction[] ): WalletContext { const connectedWallets: Record = {} @@ -122,6 +128,7 @@ function buildWalletContext( safeAddress, safeDeploymentState, registryOrders, + knownTransactions, } } @@ -343,12 +350,7 @@ Many tools render UI cards (as noted in their descriptions). After a tool with a For tools without UI cards, format and present data directly in your response. -**Transaction history optimization:** Set renderTransactions based on user intent: -- "last transaction" → 1 -- "recent txs" → 3-5 -- "all transactions" → 10-20 -- Aggregation queries (counts, sums) → false -- Type-specific queries ("last swap", "my sends") → set types filter (e.g. ["swap"]) so rendered cards match your description +**Transaction history:** Single call with all parameters. Set types when asking about a specific type. @@ -468,6 +470,19 @@ const chatRequestSchema = z.object({ }) ) .optional(), + knownTransactions: z + .array( + z.object({ + txHash: z.string(), + type: z.enum(['swap', 'send', 'limitOrder', 'stopLoss', 'twap', 'deposit', 'withdraw', 'approval']), + sellSymbol: z.string().optional(), + sellAmount: z.string().optional(), + buySymbol: z.string().optional(), + buyAmount: z.string().optional(), + network: z.string().optional(), + }) + ) + .optional(), registryOrders: z .array( z.object({ @@ -502,8 +517,16 @@ export async function handleChatRequest(c: Context) { return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400) } - const { messages, evmAddress, solanaAddress, approvedChainIds, safeAddress, safeDeploymentState, registryOrders } = - parsed.data + const { + messages, + evmAddress, + solanaAddress, + approvedChainIds, + safeAddress, + safeDeploymentState, + knownTransactions, + registryOrders, + } = parsed.data // Build wallet context from addresses (filtered by approved chains if provided) const walletContext = buildWalletContext( @@ -512,7 +535,8 @@ export async function handleChatRequest(c: Context) { approvedChainIds, safeAddress, safeDeploymentState, - registryOrders + registryOrders, + knownTransactions ) // Convert UIMessages to ModelMessages @@ -522,7 +546,7 @@ export async function handleChatRequest(c: Context) { model: getModel(), messages: modelMessages, system: buildSystemPrompt(evmAddress, solanaAddress, approvedChainIds, safeDeploymentState), - temperature: 0.6, + temperature: 0.3, stopWhen: stepCountIs(5), tools: buildTools(walletContext), // Venice-specific parameters to disable reasoning for faster responses diff --git a/apps/agentic-server/src/tools/transactionHistory.ts b/apps/agentic-server/src/tools/transactionHistory.ts index 9a21eb22..23d282d8 100644 --- a/apps/agentic-server/src/tools/transactionHistory.ts +++ b/apps/agentic-server/src/tools/transactionHistory.ts @@ -3,6 +3,7 @@ import type { Network } from '@shapeshiftoss/types' import { executeAggregations } from '../lib/transactionHistory/aggregations' import type { AggregationConfig } from '../lib/transactionHistory/aggregations/types' import { MAX_LIMITED_FETCH_COUNT } from '../lib/transactionHistory/constants' +import { enrichTransactions } from '../lib/transactionHistory/enrichment' import { determineFetchStrategy, fetchTransactions, @@ -49,7 +50,7 @@ export async function executeTransactionHistory( fetchedCount, } = await fetchTransactions(networks, addressOrWallet, strategy) - let transactions = allTransactions + let transactions = enrichTransactions(allTransactions, walletContext?.knownTransactions) transactions = filter(transactions, { types: input.types, @@ -69,12 +70,14 @@ export async function executeTransactionHistory( ? executeAggregations(transactions, input.aggregations as AggregationConfig[]) : undefined - // Paginate only the returned transactions - const paginatedTransactions = paginate(transactions, input.offset ?? 0, input.limit) - - // Respect explicit includeTransactions: false const shouldIncludeTransactions = input.includeTransactions ?? !input.aggregations + // Cap at 25 transactions in agent context unless explicitly overridden + const effectiveLimit = input.limit ?? (shouldIncludeTransactions ? 25 : undefined) + + // Paginate only the returned transactions + const paginatedTransactions = paginate(transactions, input.offset ?? 0, effectiveLimit) + const mayBeIncomplete = strategy.mode === 'limited' && fetchedCount >= MAX_LIMITED_FETCH_COUNT return { @@ -111,16 +114,7 @@ export async function executeTransactionHistory( } export const transactionHistoryTool = { - description: `Query transaction history with filters. - -UI CARD DISPLAYS: transaction list with type, amount, status, and timestamps. - -**Parameter Guidance:** -- For "last transaction" or "most recent tx" queries: Use renderTransactions: 1 -- For "recent transactions" or "last few" queries: Use renderTransactions: 3-5 -- For "all transactions" or large date ranges: Leave renderTransactions unset or use a reasonable limit (10-20) -- This prevents UI crashes when rendering large transaction lists -- For type-specific queries ("last swap", "recent sends", "show my trades"): Set the types filter (e.g., types: ["swap"]) — without it, the UI card may display an unrelated transaction that happens to be more recent`, + description: `Query transaction history. ALWAYS set types when user asks about a specific type: "swap/trade" → types: ["swap"], "send/sent" → types: ["send"], "receive" → types: ["receive"]. Set renderTransactions for UI cards (1 for "last", 3-5 for "recent"). Returns max 25 transactions by default; set limit explicitly for more. Single call only.`, inputSchema: transactionHistorySchema, execute: executeTransactionHistory, } diff --git a/apps/agentic-server/src/utils/walletContextSimple.ts b/apps/agentic-server/src/utils/walletContextSimple.ts index 7ba1ba1a..2719388b 100644 --- a/apps/agentic-server/src/utils/walletContextSimple.ts +++ b/apps/agentic-server/src/utils/walletContextSimple.ts @@ -1,6 +1,7 @@ import type { Network } from '@shapeshiftoss/types' import { networkToChainIdMap } from '@shapeshiftoss/types' +import { predictSafeAddress } from './predictSafeAddress' import { verifySafeOwnership } from './safeAddressVerification' export interface SafeChainDeployment { @@ -30,11 +31,22 @@ export interface ActiveOrderSummary { numParts?: number } +export interface KnownTransaction { + txHash: string + type: 'swap' | 'send' | 'limitOrder' | 'stopLoss' | 'twap' | 'deposit' | 'withdraw' | 'approval' + sellSymbol?: string + sellAmount?: string + buySymbol?: string + buyAmount?: string + network?: string +} + export interface WalletContext { connectedWallets?: Record safeAddress?: string safeDeploymentState?: Record registryOrders?: ActiveOrderSummary[] + knownTransactions?: KnownTransaction[] } export function getAddressForNetwork(walletContext: WalletContext | undefined, network: Network): string { @@ -67,13 +79,19 @@ export async function getSafeAddressForChain( walletContext: WalletContext | undefined, chainId: number ): Promise { - const safeAddress = walletContext?.safeDeploymentState?.[chainId]?.safeAddress ?? walletContext?.safeAddress - if (!safeAddress) return undefined + const knownAddress = walletContext?.safeDeploymentState?.[chainId]?.safeAddress + if (knownAddress) { + const caipChainId = `eip155:${chainId}` + const ownerAddress = walletContext?.connectedWallets?.[caipChainId]?.address + if (ownerAddress) await verifySafeOwnership(knownAddress, ownerAddress, chainId) + return knownAddress + } const caipChainId = `eip155:${chainId}` const ownerAddress = walletContext?.connectedWallets?.[caipChainId]?.address - if (!ownerAddress) return safeAddress + if (!ownerAddress) return walletContext?.safeAddress + const safeAddress = await predictSafeAddress(ownerAddress, chainId) await verifySafeOwnership(safeAddress, ownerAddress, chainId) return safeAddress } diff --git a/bun.lock b/bun.lock index 20ea7346..aab5c6fb 100644 --- a/bun.lock +++ b/bun.lock @@ -71,6 +71,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", @@ -1577,7 +1578,7 @@ "i18next": ["i18next@23.4.6", "", { "dependencies": { "@babel/runtime": "^7.22.5" } }, "sha512-jBE8bui969Ygv7TVYp0pwDZB7+he0qsU+nz7EcfdqSh+QvKjEfl9YPRQd/KrGiMhTYFGkeuPaeITenKK/bSFDg=="], - "idb-keyval": ["idb-keyval@6.2.1", "", {}, "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="], + "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -2453,6 +2454,8 @@ "@base-org/account/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + "@base-org/account/idb-keyval": ["idb-keyval@6.2.1", "", {}, "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="], + "@base-org/account/ox": ["ox@0.6.9", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug=="], "@base-org/account/zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="], @@ -2765,6 +2768,8 @@ "@walletconnect/jsonrpc-ws-connection/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "@walletconnect/keyvaluestorage/idb-keyval": ["idb-keyval@6.2.1", "", {}, "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="], + "@walletconnect/relay-auth/@noble/curves": ["@noble/curves@1.8.0", "", { "dependencies": { "@noble/hashes": "1.7.0" } }, "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ=="], "@walletconnect/relay-auth/@noble/hashes": ["@noble/hashes@1.7.0", "", {}, "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w=="], @@ -2843,6 +2848,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "porto/idb-keyval": ["idb-keyval@6.2.1", "", {}, "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -3095,6 +3102,8 @@ "@wagmi/connectors/@base-org/account/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + "@wagmi/connectors/@base-org/account/idb-keyval": ["idb-keyval@6.2.1", "", {}, "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="], + "@wagmi/connectors/@base-org/account/ox": ["ox@0.6.9", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug=="], "@wagmi/connectors/@base-org/account/zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="], @@ -3103,6 +3112,8 @@ "@wagmi/connectors/@coinbase/wallet-sdk/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + "@wagmi/connectors/@coinbase/wallet-sdk/idb-keyval": ["idb-keyval@6.2.1", "", {}, "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="], + "@wagmi/connectors/@coinbase/wallet-sdk/ox": ["ox@0.6.9", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug=="], "@wagmi/connectors/@coinbase/wallet-sdk/zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="], diff --git a/packages/types/src/tx.ts b/packages/types/src/tx.ts index 768c4934..8caab9a4 100644 --- a/packages/types/src/tx.ts +++ b/packages/types/src/tx.ts @@ -22,7 +22,18 @@ export type TokenTransfer = { icon?: string } -export const TRANSACTION_TYPES = ['send', 'receive', 'swap', 'contract'] as const +export const TRANSACTION_TYPES = [ + 'send', + 'receive', + 'swap', + 'contract', + 'limitOrder', + 'stopLoss', + 'twap', + 'deposit', + 'withdraw', + 'approval', +] as const export type TransactionType = (typeof TRANSACTION_TYPES)[number] export const TRANSACTION_STATUSES = ['success', 'failed'] as const @@ -66,7 +77,53 @@ export type ContractTransaction = BaseTransaction & { tokenTransfers?: TokenTransfer[] } -export type ParsedTransaction = SendTransaction | ReceiveTransaction | SwapTransaction | ContractTransaction +export type LimitOrderTransaction = BaseTransaction & { + type: 'limitOrder' + value: string + tokenTransfers?: TokenTransfer[] +} + +export type StopLossTransaction = BaseTransaction & { + type: 'stopLoss' + value: string + tokenTransfers?: TokenTransfer[] +} + +export type TwapTransaction = BaseTransaction & { + type: 'twap' + value: string + tokenTransfers?: TokenTransfer[] +} + +export type DepositTransaction = BaseTransaction & { + type: 'deposit' + value: string + tokenTransfers?: TokenTransfer[] +} + +export type WithdrawTransaction = BaseTransaction & { + type: 'withdraw' + value: string + tokenTransfers?: TokenTransfer[] +} + +export type ApprovalTransaction = BaseTransaction & { + type: 'approval' + value: string + tokenTransfers?: TokenTransfer[] +} + +export type ParsedTransaction = + | SendTransaction + | ReceiveTransaction + | SwapTransaction + | ContractTransaction + | LimitOrderTransaction + | StopLossTransaction + | TwapTransaction + | DepositTransaction + | WithdrawTransaction + | ApprovalTransaction export function isSendTransaction(tx: ParsedTransaction): tx is SendTransaction { return tx.type === 'send'