Skip to content
Closed
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
Expand Up @@ -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' }]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
39 changes: 39 additions & 0 deletions apps/agentic-server/src/lib/transactionHistory/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
// 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
])
4 changes: 2 additions & 2 deletions apps/agentic-server/src/lib/transactionHistory/enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions apps/agentic-server/src/lib/transactionHistory/evmParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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'
}
Expand Down Expand Up @@ -261,7 +266,7 @@ export function parseEvmTransaction(tx: EvmTx, userAddress: string, network: Net
return {
...baseTransaction,
type,
tokenTransfers: tokenTransfers!,
tokenTransfers: tokenTransfers ?? [],
}
}

Expand Down
Loading