diff --git a/apps/agentic-server/src/lib/transactionHistory/__tests__/enrichment.test.ts b/apps/agentic-server/src/lib/transactionHistory/__tests__/enrichment.test.ts index a8a87b5d..faec1b35 100644 --- a/apps/agentic-server/src/lib/transactionHistory/__tests__/enrichment.test.ts +++ b/apps/agentic-server/src/lib/transactionHistory/__tests__/enrichment.test.ts @@ -165,6 +165,56 @@ describe('enrichTransactions', () => { expect(result.type).toBe('approval') }) + test('enriches selector-matched swap with empty tokenTransfers when known tx exists', () => { + const known: KnownTransaction[] = [ + { + txHash: '0xswap1', + type: 'swap', + sellSymbol: 'ETH', + sellAmount: '1.5', + buySymbol: 'USDC', + buyAmount: '3000', + }, + ] + + const result = enrichOne(makeTx({ txid: '0xswap1', type: 'swap', tokenTransfers: [] }), known) + expect(result.type).toBe('swap') + expect(result.tokenTransfers).toHaveLength(2) + expect(result.tokenTransfers![0]!.symbol).toBe('ETH') + expect(result.tokenTransfers![1]!.symbol).toBe('USDC') + }) + + test('preserves existing tokenTransfers on swap with sufficient data', () => { + const existingTransfers = [ + { symbol: 'WETH', amount: '-0.5', decimals: 18, from: '0xUser', to: '0xRouter', assetId: 'eip155:1/erc20:0x' }, + { symbol: 'DAI', amount: '1000', decimals: 18, from: '0xRouter', to: '0xUser', assetId: 'eip155:1/erc20:0xdai' }, + ] + 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('enriches send with no tokenTransfers when matching known swap exists', () => { + const known: KnownTransaction[] = [ + { + txHash: '0xsend1', + type: 'swap', + sellSymbol: 'ETH', + sellAmount: '1', + buySymbol: 'USDC', + buyAmount: '2000', + }, + ] + + const result = enrichOne(makeTx({ txid: '0xsend1', type: 'send', tokenTransfers: undefined }), known) + expect(result.type).toBe('swap') + expect(result.tokenTransfers).toHaveLength(2) + }) + test('reclassifies order type with partial swap info and attaches sell transfer', () => { const known: KnownTransaction[] = [{ txHash: '0xlim', type: 'limitOrder', sellSymbol: 'ETH', sellAmount: '1' }] 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 0d82cad7..48fb35b1 100644 --- a/apps/agentic-server/src/lib/transactionHistory/__tests__/evmParser.test.ts +++ b/apps/agentic-server/src/lib/transactionHistory/__tests__/evmParser.test.ts @@ -491,6 +491,78 @@ describe('parseEvmTransaction', () => { }) }) + describe('swap selector matching', () => { + test('classifies as swap when inputData matches a known swap selector', () => { + const tx = makeTx({ + from: USER, + to: ROUTER, + inputData: '0x38ed17390000000000000000000000000000000000000000000000000000000000000001', + }) + const result = parseEvmTransaction(tx, USER, 'ethereum') + expect(result.type).toBe('swap') + expect(result.tokenTransfers).toEqual([]) + }) + + test('token-transfer classification takes priority over selector', () => { + const tx = makeTx({ + from: USER, + to: ROUTER, + inputData: '0x38ed17390000000000000000000000000000000000000000000000000000000000000001', + tokenTransfers: [ + makeTokenTransfer({ contract: USDC_CONTRACT, symbol: 'USDC', from: USER, to: ROUTER, value: '1000000' }), + makeTokenTransfer({ + contract: DAI_CONTRACT, + symbol: 'DAI', + decimals: 18, + from: ROUTER, + to: USER, + value: '1000000000000000000', + }), + ], + }) + const result = parseEvmTransaction(tx, USER, 'ethereum') + expect(result.type).toBe('swap') + expect(result.tokenTransfers!.length).toBe(2) + expect(result.tokenTransfers![0].symbol).toBe('USDC') + }) + + test('unknown selector falls through to contract', () => { + const tx = makeTx({ + from: USER, + to: ROUTER, + inputData: '0xdeadbeef0000000000000000000000000000000000000000000000000000000000000001', + }) + const result = parseEvmTransaction(tx, USER, 'ethereum') + expect(result.type).toBe('contract') + }) + + test('short inputData does not crash', () => { + const tx = makeTx({ from: USER, to: ROUTER, inputData: '0x38ed' }) + const result = parseEvmTransaction(tx, USER, 'ethereum') + expect(result.type).toBe('contract') + }) + + test('matches selectors case-insensitively', () => { + const tx = makeTx({ + from: USER, + to: ROUTER, + inputData: '0x38ED17390000000000000000000000000000000000000000000000000000000000000001', + }) + const result = parseEvmTransaction(tx, USER, 'ethereum') + expect(result.type).toBe('swap') + }) + + test('does not match swap selector when to is user address (self-send)', () => { + const tx = makeTx({ + from: USER, + to: USER, + inputData: '0x38ed17390000000000000000000000000000000000000000000000000000000000000001', + }) + const result = parseEvmTransaction(tx, USER, 'ethereum') + expect(result.type).not.toBe('swap') + }) + }) + describe('decimals fallback', () => { test('uses PRECISION_HIGH (18) when decimals is undefined', () => { const tx = makeTx({ diff --git a/apps/agentic-server/src/lib/transactionHistory/constants.ts b/apps/agentic-server/src/lib/transactionHistory/constants.ts index 0cf3c7b7..b32bf5f1 100644 --- a/apps/agentic-server/src/lib/transactionHistory/constants.ts +++ b/apps/agentic-server/src/lib/transactionHistory/constants.ts @@ -9,3 +9,42 @@ export const SOLANA_NATIVE_DECIMALS = 9 // Fetch limits export const MAX_LIMITED_FETCH_COUNT = 200 + +// 4-byte function selectors for well-known DEX swap methods. +// Some selectors (Universal Router execute, THORChain deposit) are multi-purpose — they may +// handle non-swap operations. This is acceptable because the selector check is a secondary +// heuristic that only fires when token-transfer classification has no signal. +export const KNOWN_SWAP_SELECTORS = new Set([ + // Uniswap V2 Router + '0x38ed1739', // swapExactTokensForTokens + '0x8803dbee', // swapTokensForExactTokens + '0x7ff36ab5', // swapExactETHForTokens + '0x4a25d94a', // swapTokensForExactETH + '0x18cbafe5', // swapExactTokensForETH + '0xfb3bdb41', // swapETHForExactTokens + // Uniswap V3 SwapRouter + '0x414bf389', // exactInputSingle + '0xc04b8d59', // exactInput + '0xdb3e2198', // exactOutputSingle + '0xf28c0498', // exactOutput + // Uniswap Universal Router + '0x3593564c', // execute + '0x24856bc3', // execute (deadline variant) + // 1inch AggregationRouterV5/V6 + '0x12aa3caf', // swap + '0xe449022e', // uniswapV3Swap + '0x0502b1c5', // unoswap + '0xf78dc253', // unoswapTo + // 0x Exchange Proxy + '0xd9627aa4', // sellToUniswap + '0x415565b0', // transformERC20 + // CowSwap GPv2Settlement + '0x13d79a0b', // settle + // Paraswap + '0x54e3f31b', // simpleSwap + '0xa94e78ef', // multiSwap + '0x46c67b6d', // megaSwap + // THORChain Router + '0x44bc937b', // depositWithExpiry + '0x1fece7b4', // deposit +]) diff --git a/apps/agentic-server/src/lib/transactionHistory/enrichment.ts b/apps/agentic-server/src/lib/transactionHistory/enrichment.ts index 8873f31e..5937b535 100644 --- a/apps/agentic-server/src/lib/transactionHistory/enrichment.ts +++ b/apps/agentic-server/src/lib/transactionHistory/enrichment.ts @@ -14,10 +14,10 @@ export function enrichTransactions( } return transactions.map((tx): ParsedTransaction => { - if (tx.type !== 'contract') return tx - const known = knownMap.get(tx.txid.toLowerCase()) if (!known) return tx + // Selector-matched swaps may have empty tokenTransfers — allow enrichment for those + if (tx.type !== 'contract' && tx.tokenTransfers && tx.tokenTransfers.length > 0) return tx const enriched = { ...tx, type: known.type, value: tx.value ?? '0' } as ParsedTransaction diff --git a/apps/agentic-server/src/lib/transactionHistory/evmParser.ts b/apps/agentic-server/src/lib/transactionHistory/evmParser.ts index 47e5bc9d..34a2fbe0 100644 --- a/apps/agentic-server/src/lib/transactionHistory/evmParser.ts +++ b/apps/agentic-server/src/lib/transactionHistory/evmParser.ts @@ -2,7 +2,7 @@ import type { Network, ParsedTransaction, TokenTransfer } from '@shapeshiftoss/t import { networkToChainIdMap, networkToNativeAssetId, networkToNativeSymbol } from '@shapeshiftoss/types' import { AssetService, fromBaseUnit } from '@shapeshiftoss/utils' -import { EVM_NATIVE_DECIMALS, PRECISION_HIGH } from './constants' +import { EVM_NATIVE_DECIMALS, KNOWN_SWAP_SELECTORS, PRECISION_HIGH } from './constants' import type { EvmTx } from './schemas' import { createAssetId } from './transactionUtils' @@ -120,6 +120,11 @@ function determineTransactionType(tx: EvmTx, userAddress: string): ParsedTransac } } + if (tx.inputData && tx.inputData.length >= 10 && normalizedTo !== normalizedUserAddress) { + const selector = tx.inputData.slice(0, 10).toLowerCase() + if (KNOWN_SWAP_SELECTORS.has(selector)) return 'swap' + } + if (tx.inputData && tx.inputData !== '0x' && normalizedTo !== normalizedUserAddress) { return 'contract' } @@ -261,7 +266,7 @@ export function parseEvmTransaction(tx: EvmTx, userAddress: string, network: Net return { ...baseTransaction, type, - tokenTransfers: tokenTransfers!, + tokenTransfers: tokenTransfers ?? [], } }