feat: create v2-prep branch with comprehensive planning

Restructured project for V2 refactor:

**Structure Changes:**
- Moved all V1 code to orig/ folder (preserved with git mv)
- Created docs/planning/ directory
- Added orig/README_V1.md explaining V1 preservation

**Planning Documents:**
- 00_V2_MASTER_PLAN.md: Complete architecture overview
  - Executive summary of critical V1 issues
  - High-level component architecture diagrams
  - 5-phase implementation roadmap
  - Success metrics and risk mitigation

- 07_TASK_BREAKDOWN.md: Atomic task breakdown
  - 99+ hours of detailed tasks
  - Every task < 2 hours (atomic)
  - Clear dependencies and success criteria
  - Organized by implementation phase

**V2 Key Improvements:**
- Per-exchange parsers (factory pattern)
- Multi-layer strict validation
- Multi-index pool cache
- Background validation pipeline
- Comprehensive observability

**Critical Issues Addressed:**
- Zero address tokens (strict validation + cache enrichment)
- Parsing accuracy (protocol-specific parsers)
- No audit trail (background validation channel)
- Inefficient lookups (multi-index cache)
- Stats disconnection (event-driven metrics)

Next Steps:
1. Review planning documents
2. Begin Phase 1: Foundation (P1-001 through P1-010)
3. Implement parsers in Phase 2
4. Build cache system in Phase 3
5. Add validation pipeline in Phase 4
6. Migrate and test in Phase 5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Administrator
2025-11-10 10:14:26 +01:00
parent 1773daffe7
commit 803de231ba
411 changed files with 20390 additions and 8680 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,234 @@
package calldata
import (
"math/big"
"strings"
"testing"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/require"
)
const testRouterABI = `[
{
"name":"multicall",
"type":"function",
"stateMutability":"payable",
"inputs":[
{"name":"deadline","type":"uint256"},
{"name":"data","type":"bytes[]"}
],
"outputs":[]
},
{
"name":"exactInputSingle",
"type":"function",
"stateMutability":"payable",
"inputs":[
{
"name":"params",
"type":"tuple",
"components":[
{"name":"tokenIn","type":"address"},
{"name":"tokenOut","type":"address"},
{"name":"fee","type":"uint24"},
{"name":"recipient","type":"address"},
{"name":"deadline","type":"uint256"},
{"name":"amountIn","type":"uint256"},
{"name":"amountOutMinimum","type":"uint256"},
{"name":"sqrtPriceLimitX96","type":"uint160"}
]
}
],
"outputs":[{"name":"","type":"uint256"}]
}
]`
type exactInputSingleParams struct {
TokenIn common.Address `abi:"tokenIn"`
TokenOut common.Address `abi:"tokenOut"`
Fee *big.Int `abi:"fee"`
Recipient common.Address `abi:"recipient"`
Deadline *big.Int `abi:"deadline"`
AmountIn *big.Int `abi:"amountIn"`
AmountOutMinimum *big.Int `abi:"amountOutMinimum"`
SqrtPriceLimitX96 *big.Int `abi:"sqrtPriceLimitX96"`
}
func TestExtractTokensFromMulticall(t *testing.T) {
routerABI, err := abi.JSON(strings.NewReader(testRouterABI))
require.NoError(t, err)
tokenIn := common.HexToAddress("0xaf88d065e77c8cc2239327c5edb3a432268e5831")
tokenOut := common.HexToAddress("0x82af49447d8a07e3bd95bd0d56f35241523fbab1")
params := exactInputSingleParams{
TokenIn: tokenIn,
TokenOut: tokenOut,
Fee: big.NewInt(500),
Recipient: common.HexToAddress("0x1111111254eeb25477b68fb85ed929f73a960582"),
Deadline: big.NewInt(0),
AmountIn: big.NewInt(1_000_000),
AmountOutMinimum: big.NewInt(900_000),
SqrtPriceLimitX96: big.NewInt(0),
}
innerCall, err := routerABI.Pack("exactInputSingle", params)
require.NoError(t, err)
multicallPayload, err := routerABI.Pack("multicall", big.NewInt(0), [][]byte{innerCall})
require.NoError(t, err)
calls, err := decodeMulticallCalls(multicallPayload[4:])
require.NoError(t, err)
require.NotEmpty(t, calls)
t.Logf("selector bytes: %x", calls[0][:4])
callTokens, err := extractTokensFromCall(calls[0])
require.NoError(t, err)
require.NotEmpty(t, callTokens)
tokens, err := ExtractTokensFromMulticall(multicallPayload[4:])
require.NoError(t, err)
require.Len(t, tokens, 2)
require.Equal(t, tokenIn, tokens[0])
require.Equal(t, tokenOut, tokens[1])
}
func TestExtractTokensFromMulticallBytesOnly(t *testing.T) {
routerABI, err := abi.JSON(strings.NewReader(testRouterABI))
require.NoError(t, err)
tokenIn := common.HexToAddress("0xaf88d065e77c8cc2239327c5edb3a432268e5831")
tokenOut := common.HexToAddress("0x82af49447d8a07e3bd95bd0d56f35241523fbab1")
params := exactInputSingleParams{
TokenIn: tokenIn,
TokenOut: tokenOut,
Fee: big.NewInt(500),
Recipient: common.HexToAddress("0x1111111254eeb25477b68fb85ed929f73a960582"),
Deadline: big.NewInt(0),
AmountIn: big.NewInt(1_000_000),
AmountOutMinimum: big.NewInt(900_000),
SqrtPriceLimitX96: big.NewInt(0),
}
innerCall, err := routerABI.Pack("exactInputSingle", params)
require.NoError(t, err)
bytesArrayType, err := abi.NewType("bytes[]", "", nil)
require.NoError(t, err)
args := abi.Arguments{{Name: "data", Type: bytesArrayType}}
encodedArgs, err := args.Pack([][]byte{innerCall})
require.NoError(t, err)
selector := crypto.Keccak256([]byte("multicall(bytes[])"))[:4]
multicallPayload := append(selector, encodedArgs...)
calls, err := decodeMulticallCalls(multicallPayload[4:])
require.NoError(t, err)
require.NotEmpty(t, calls)
tokens, err := ExtractTokensFromMulticall(multicallPayload[4:])
require.NoError(t, err)
require.Len(t, tokens, 2)
require.Equal(t, tokenIn, tokens[0])
require.Equal(t, tokenOut, tokens[1])
}
func TestExtractTokensFromMulticallFiltersSuspiciousTokens(t *testing.T) {
routerABI, err := abi.JSON(strings.NewReader(testRouterABI))
require.NoError(t, err)
tokenIn := common.HexToAddress("0xaf88d065e77c8cc2239327c5edb3a432268e5831")
suspicious := common.HexToAddress("0x0000000000000000000000000000000000001234")
params := exactInputSingleParams{
TokenIn: tokenIn,
TokenOut: suspicious,
Fee: big.NewInt(500),
Recipient: common.HexToAddress("0x1111111254eeb25477b68fb85ed929f73a960582"),
Deadline: big.NewInt(0),
AmountIn: big.NewInt(1_000_000),
AmountOutMinimum: big.NewInt(900_000),
SqrtPriceLimitX96: big.NewInt(0),
}
innerCall, err := routerABI.Pack("exactInputSingle", params)
require.NoError(t, err)
multicallPayload, err := routerABI.Pack("multicall", big.NewInt(0), [][]byte{innerCall})
require.NoError(t, err)
tokens, err := ExtractTokensFromMulticall(multicallPayload[4:])
require.NoError(t, err)
require.Len(t, tokens, 1)
require.Equal(t, tokenIn, tokens[0])
}
func TestExtractTokensFromMulticallAllSuspicious(t *testing.T) {
routerABI, err := abi.JSON(strings.NewReader(testRouterABI))
require.NoError(t, err)
suspiciousIn := common.HexToAddress("0x0000000000000000000000000000000000000005")
suspiciousOut := common.HexToAddress("0x0000000000000000000000000000000000001234")
params := exactInputSingleParams{
TokenIn: suspiciousIn,
TokenOut: suspiciousOut,
Fee: big.NewInt(500),
Recipient: common.HexToAddress("0x1111111254eeb25477b68fb85ed929f73a960582"),
Deadline: big.NewInt(0),
AmountIn: big.NewInt(1_000_000),
AmountOutMinimum: big.NewInt(900_000),
SqrtPriceLimitX96: big.NewInt(0),
}
innerCall, err := routerABI.Pack("exactInputSingle", params)
require.NoError(t, err)
multicallPayload, err := routerABI.Pack("multicall", big.NewInt(0), [][]byte{innerCall})
require.NoError(t, err)
tokens, err := ExtractTokensFromMulticall(multicallPayload[4:])
// CRITICAL FIX: Updated test to expect new error behavior when all addresses are suspicious
if err != nil {
// New behavior: error returned when no valid tokens found
require.Contains(t, err.Error(), "no tokens extracted")
require.Len(t, tokens, 0)
} else {
// Fallback: empty result if no error returned
require.Len(t, tokens, 0)
}
}
func TestExtractTokensFromMulticallHeuristicFallback(t *testing.T) {
routerABI, err := abi.JSON(strings.NewReader(testRouterABI))
require.NoError(t, err)
tokenIn := common.HexToAddress("0xaf88d065e77c8cc2239327c5edb3a432268e5831")
tokenOut := common.HexToAddress("0x82af49447d8a07e3bd95bd0d56f35241523fbab1")
unknownCall := make([]byte, 4+64)
copy(unknownCall[:4], []byte{0xde, 0xad, 0xbe, 0xef})
copy(unknownCall[4+12:4+32], tokenIn.Bytes())
copy(unknownCall[4+32+12:4+64], tokenOut.Bytes())
multicallPayload, err := routerABI.Pack("multicall", big.NewInt(0), [][]byte{unknownCall})
require.NoError(t, err)
tokens, err := ExtractTokensFromMulticall(multicallPayload[4:])
require.NoError(t, err)
require.Len(t, tokens, 2)
require.Equal(t, tokenIn, tokens[0])
require.Equal(t, tokenOut, tokens[1])
}
func TestIsLikelyValidTokenRecognizesKnownTokens(t *testing.T) {
validator := getAddressValidator()
addr := common.HexToAddress("0xaf88d065e77c8cc2239327c5edb3a432268e5831")
if !isLikelyValidToken(addr, validator) {
t.Fatalf("expected known token address to be considered valid")
}
}

728
orig/pkg/calldata/swaps.go Normal file
View File

@@ -0,0 +1,728 @@
package calldata
import (
"encoding/binary"
"encoding/hex"
"fmt"
"math/big"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/fraktal/mev-beta/internal/validation"
"github.com/fraktal/mev-beta/pkg/common/selectors"
"github.com/fraktal/mev-beta/pkg/uniswap"
)
// safeConvertUint64ToInt is defined in multicall.go to avoid duplication
// SwapCall represents a decoded swap-like call discovered inside a multicall payload.
type SwapCall struct {
Selector string
Protocol string
TokenIn common.Address
TokenOut common.Address
Recipient common.Address
AmountIn *big.Int
AmountOut *big.Int
AmountOutMinimum *big.Int
Fee uint32
Path []common.Address
Factory common.Address
PoolAddress common.Address
Pools []common.Address
Fees []uint32
Deadline *big.Int
Raw []byte
nested bool
heuristicFallback bool
}
// DecodeSwapCallsFromMulticall walks a multicall payload and returns decoded swap calls.
func DecodeSwapCallsFromMulticall(data []byte, ctx *MulticallContext) ([]*SwapCall, error) {
calls, err := decodeMulticallCalls(data)
if err != nil {
return nil, err
}
validator := getAddressValidator()
results := make([]*SwapCall, 0)
for _, call := range calls {
results = append(results, decodeSwapCallRecursive(call, ctx, validator, 0)...)
}
if len(results) == 0 && ctx == nil {
heuristicTokens := heuristicExtractTokens(calls, validator)
if len(heuristicTokens) >= 2 {
for i := 0; i+1 < len(heuristicTokens); i += 2 {
swap := &SwapCall{
Selector: "heuristic",
Protocol: "Unclassified",
TokenIn: heuristicTokens[i],
TokenOut: heuristicTokens[i+1],
heuristicFallback: true,
}
results = append(results, swap)
}
}
}
return results, nil
}
// DecodeSwapCall attempts to decode a single swap call payload.
func DecodeSwapCall(data []byte, ctx *MulticallContext) (*SwapCall, error) {
if len(data) < 4 {
return nil, fmt.Errorf("payload too short to contain function selector")
}
selector := normalizedSelector(hex.EncodeToString(data[:4]))
swap := decodeDirectSwap(selector, data[4:], ctx)
if swap == nil {
return nil, fmt.Errorf("unsupported swap selector 0x%s", selector)
}
swap.Raw = append([]byte(nil), data...)
return swap, nil
}
func normalizedSelector(sel string) string {
return strings.TrimPrefix(strings.ToLower(sel), "0x")
}
func decodeSwapCallRecursive(call []byte, ctx *MulticallContext, validator *validation.AddressValidator, depth int) []*SwapCall {
if len(call) < 4 || depth > 6 {
return nil
}
selector := strings.ToLower(hex.EncodeToString(call[:4]))
payload := call[4:]
switch selector {
case normalizedSelector(selectors.UniswapV3MulticallWithDeadline),
normalizedSelector(selectors.UniswapV3MulticallWithBlockhash),
normalizedSelector(selectors.UniswapV3Multicall):
nested, err := DecodeSwapCallsFromMulticall(payload, ctx)
if err != nil {
recordSuspiciousMulticall(payload, 0, ctx)
return nil
}
markNested(nested)
return nested
case normalizedSelector(selectors.UniversalRouterExecute), normalizedSelector(selectors.UniversalRouterExecutePayable):
return decodeUniversalRouterExecute(payload, ctx, validator, depth+1)
}
if swap := decodeDirectSwap(selector, payload, ctx); swap != nil {
swap.Raw = call
return []*SwapCall{swap}
}
return nil
}
func decodeDirectSwap(selector string, payload []byte, ctx *MulticallContext) *SwapCall {
switch selector {
case normalizedSelector(selectors.UniswapV3ExactInputSingle), normalizedSelector(selectors.UniswapV3ExactInputSingleLegacy):
return decodeExactInputSingle(payload, ctx)
case normalizedSelector(selectors.UniswapV3ExactOutputSingle):
return decodeExactOutputSingle(payload, ctx)
case normalizedSelector(selectors.UniswapV3ExactInput), normalizedSelector(selectors.UniswapV3ExactInputLegacy):
return decodeExactInput(payload, ctx)
case normalizedSelector(selectors.UniswapV3ExactOutput), normalizedSelector(selectors.UniswapV3ExactOutputBytes):
return decodeExactOutput(payload, ctx)
case normalizedSelector(selectors.UniswapV2SwapExactTokensForTokens),
normalizedSelector(selectors.UniswapV2SwapTokensForExactTokens),
normalizedSelector(selectors.UniswapV2SwapExactETHForTokens),
normalizedSelector(selectors.UniswapV2SwapExactTokensForETH),
normalizedSelector(selectors.UniswapV2SwapExactTokensForTokensSupportingFee),
normalizedSelector(selectors.UniswapV2SwapExactTokensForTokensSupportingFeeLegacy),
normalizedSelector(selectors.UniswapV2SwapExactETHForTokensSupportingFee),
normalizedSelector(selectors.UniswapV2SwapExactTokensForETHSupportingFee):
return decodeUniswapV2Swap(selector, payload, ctx)
case normalizedSelector(selectors.NonfungiblePositionManagerMint):
return decodePositionManagerMint(payload, ctx)
default:
return nil
}
}
func decodeUniversalRouterExecute(payload []byte, ctx *MulticallContext, validator *validation.AddressValidator, depth int) []*SwapCall {
if len(payload) < 96 {
return nil
}
commandsOffset, ok := readInt(payload[0:32])
if !ok {
return nil
}
inputsOffset, ok := readInt(payload[32:64])
if !ok {
return nil
}
commands, ok := readBytesAt(payload, commandsOffset)
if !ok {
return nil
}
inputs, ok := readBytesArrayAt(payload, inputsOffset)
if !ok {
return nil
}
var results []*SwapCall
for idx, input := range inputs {
if len(commands) > idx {
// command interpretation can refine protocol selection later if needed.
}
results = append(results, decodeSwapCallRecursive(input, ctx, validator, depth+1)...)
}
return results
}
func decodeExactInputSingle(payload []byte, ctx *MulticallContext) *SwapCall {
if len(payload) < 256 {
return nil
}
tokenIn := common.BytesToAddress(payload[12:32])
tokenOut := common.BytesToAddress(payload[44:64])
fee := binary.BigEndian.Uint32(append(make([]byte, 1), payload[93:96]...))
recipient := common.BytesToAddress(payload[108:128])
deadline := new(big.Int).SetBytes(payload[128:160])
amountIn := new(big.Int).SetBytes(payload[160:192])
amountOutMin := new(big.Int).SetBytes(payload[192:224])
swap := &SwapCall{
Selector: selectors.UniswapV3ExactInputSingle,
Protocol: defaultProtocol(contextProtocol(ctx), "UniswapV3"),
TokenIn: tokenIn,
TokenOut: tokenOut,
Recipient: recipient,
Deadline: deadline,
AmountIn: amountIn,
AmountOutMinimum: amountOutMin,
Fee: uint32(fee),
Path: []common.Address{tokenIn, tokenOut},
}
swap.Factory = common.HexToAddress("0x1F98431c8aD98523631AE4a59f267346ea31F984")
swap.ensurePoolAddress()
swap.Fees = []uint32{uint32(fee)}
swap.Fees = []uint32{uint32(fee)}
return swap
}
func decodeExactInput(payload []byte, ctx *MulticallContext) *SwapCall {
if len(payload) < 160 {
return nil
}
pathOffset, ok := readInt(payload[0:32])
if !ok {
return nil
}
recipient := common.BytesToAddress(payload[44:64])
deadline := new(big.Int).SetBytes(payload[64:96])
amountIn := new(big.Int).SetBytes(payload[96:128])
amountOutMin := new(big.Int).SetBytes(payload[128:160])
pathBytes, ok := readBytesAt(payload, pathOffset)
if !ok || len(pathBytes) < 40 {
return nil
}
tokens, fees := parseUniswapV3FullPath(pathBytes)
tokenIn := common.Address{}
tokenOut := common.Address{}
if len(tokens) >= 1 {
tokenIn = tokens[0]
}
if len(tokens) >= 1 {
tokenOut = tokens[len(tokens)-1]
}
fee := uint32(0)
if len(fees) > 0 {
fee = fees[0]
}
swap := &SwapCall{
Selector: selectors.UniswapV3ExactInput,
Protocol: defaultProtocol(contextProtocol(ctx), "UniswapV3"),
TokenIn: tokenIn,
TokenOut: tokenOut,
Recipient: recipient,
Deadline: deadline,
AmountIn: amountIn,
AmountOutMinimum: amountOutMin,
Fee: fee,
}
swap.Factory = common.HexToAddress("0x1F98431c8aD98523631AE4a59f267346ea31F984")
swap.Path = tokens
swap.Fees = fees
swap.ensurePoolAddress()
return swap
}
func decodeExactOutput(payload []byte, ctx *MulticallContext) *SwapCall {
if len(payload) < 160 {
return nil
}
pathOffset, ok := readInt(payload[0:32])
if !ok {
return nil
}
recipient := common.BytesToAddress(payload[44:64])
deadline := new(big.Int).SetBytes(payload[64:96])
amountOut := new(big.Int).SetBytes(payload[96:128])
amountInMax := new(big.Int).SetBytes(payload[128:160])
pathBytes, ok := readBytesAt(payload, pathOffset)
if !ok || len(pathBytes) < 40 {
return nil
}
tokens, fees := parseUniswapV3FullPath(pathBytes)
tokenOut := common.Address{}
tokenIn := common.Address{}
if len(tokens) >= 1 {
tokenOut = tokens[0]
}
if len(tokens) >= 2 {
tokenIn = tokens[len(tokens)-1]
}
fee := uint32(0)
if len(fees) > 0 {
fee = fees[len(fees)-1]
}
swap := &SwapCall{
Selector: selectors.UniswapV3ExactOutput,
Protocol: defaultProtocol(contextProtocol(ctx), "UniswapV3"),
TokenIn: tokenIn,
TokenOut: tokenOut,
Recipient: recipient,
Deadline: deadline,
AmountIn: amountInMax,
AmountOut: amountOut,
Fee: fee,
}
swap.Factory = common.HexToAddress("0x1F98431c8aD98523631AE4a59f267346ea31F984")
swap.Path = tokens
swap.Fees = fees
swap.ensurePoolAddress()
return swap
}
func decodeExactOutputSingle(payload []byte, ctx *MulticallContext) *SwapCall {
if len(payload) < 256 {
return nil
}
tokenIn := common.BytesToAddress(payload[12:32])
tokenOut := common.BytesToAddress(payload[44:64])
fee := binary.BigEndian.Uint32(append(make([]byte, 1), payload[93:96]...))
recipient := common.BytesToAddress(payload[108:128])
deadline := new(big.Int).SetBytes(payload[128:160])
amountOut := new(big.Int).SetBytes(payload[160:192])
amountInMax := new(big.Int).SetBytes(payload[192:224])
swap := &SwapCall{
Selector: selectors.UniswapV3ExactOutputSingle,
Protocol: defaultProtocol(contextProtocol(ctx), "UniswapV3"),
TokenIn: tokenIn,
TokenOut: tokenOut,
Recipient: recipient,
Deadline: deadline,
AmountIn: amountInMax,
AmountOut: amountOut,
Fee: uint32(fee),
Path: []common.Address{tokenIn, tokenOut},
}
swap.Factory = common.HexToAddress("0x1F98431c8aD98523631AE4a59f267346ea31F984")
swap.Fees = []uint32{uint32(fee)}
swap.ensurePoolAddress()
return swap
}
func decodeUniswapV2Swap(selector string, payload []byte, ctx *MulticallContext) *SwapCall {
if len(payload) < 160 {
return nil
}
pathOffset, ok := readInt(payload[64:96])
if !ok {
return nil
}
path, ok := readAddressArray(payload, pathOffset)
if !ok || len(path) < 2 {
return nil
}
tokenIn := path[0]
tokenOut := path[len(path)-1]
protocol := defaultProtocol(contextProtocol(ctx), "UniswapV2")
amountA := new(big.Int).SetBytes(payload[0:32])
amountB := new(big.Int).SetBytes(payload[32:64])
swap := &SwapCall{
Selector: selector,
Protocol: protocol,
TokenIn: tokenIn,
TokenOut: tokenOut,
Path: path,
}
switch selector {
case normalizedSelector(selectors.UniswapV2SwapExactTokensForTokens),
normalizedSelector(selectors.UniswapV2SwapExactETHForTokens),
normalizedSelector(selectors.UniswapV2SwapExactTokensForTokensSupportingFee),
normalizedSelector(selectors.UniswapV2SwapExactTokensForTokensSupportingFeeLegacy),
normalizedSelector(selectors.UniswapV2SwapExactETHForTokensSupportingFee),
normalizedSelector(selectors.UniswapV2SwapExactTokensForETHSupportingFee):
swap.AmountIn = amountA
swap.AmountOutMinimum = amountB
case normalizedSelector(selectors.UniswapV2SwapTokensForExactTokens),
normalizedSelector(selectors.UniswapV2SwapTokensForExactETH),
normalizedSelector(selectors.UniswapV2SwapETHForExactTokens):
swap.AmountOut = amountA
swap.AmountIn = amountB
default:
swap.AmountIn = amountA
swap.AmountOut = amountB
}
swap.Recipient = common.BytesToAddress(payload[96+12 : 128])
swap.Deadline = new(big.Int).SetBytes(payload[128:160])
if strings.Contains(strings.ToLower(protocol), "sushi") {
swap.Factory = common.HexToAddress("0xC0AEe478e3658e2610c5F7A4A2E1777cE9e4f2Ac")
} else {
// Default to canonical Arbitrum Uniswap V2 factory (f1d7cc...) set in parser.
swap.Factory = common.HexToAddress("0xf1D7CC64Fb4452F05c498126312eBE29f30Fbcf9")
}
swap.ensureV2PoolAddress()
return swap
}
func decodePositionManagerMint(payload []byte, ctx *MulticallContext) *SwapCall {
if len(payload) < 320 {
return nil
}
paramsOffset, ok := readInt(payload[0:32])
if !ok {
return nil
}
paramsStart := paramsOffset
if paramsStart+352 > len(payload) {
return nil
}
params := payload[paramsStart : paramsStart+352]
token0 := common.BytesToAddress(params[12:32])
token1 := common.BytesToAddress(params[44:64])
fee := binary.BigEndian.Uint32(append(make([]byte, 1), params[93:96]...))
amount0Desired := new(big.Int).SetBytes(params[160:192])
amount1Desired := new(big.Int).SetBytes(params[192:224])
recipient := common.BytesToAddress(params[276:296])
deadline := new(big.Int).SetBytes(params[296:328])
swap := &SwapCall{
Selector: selectors.NonfungiblePositionManagerMint,
Protocol: defaultProtocol(contextProtocol(ctx), "UniswapV3"),
TokenIn: token0,
TokenOut: token1,
Fee: fee,
AmountIn: amount0Desired,
AmountOut: amount1Desired,
Recipient: recipient,
Deadline: deadline,
}
swap.Factory = common.HexToAddress("0x1F98431c8aD98523631AE4a59f267346ea31F984")
swap.ensurePoolAddress()
return swap
}
func (sc *SwapCall) ensurePoolAddress() {
if sc.PoolAddress != (common.Address{}) && len(sc.Pools) > 0 {
return
}
if sc.Factory == (common.Address{}) {
return
}
tokens := sc.Path
fees := sc.Fees
if len(tokens) < 2 {
tokens = []common.Address{sc.TokenIn, sc.TokenOut}
}
pools := make([]common.Address, 0)
for i := 0; i+1 < len(tokens); i++ {
fee := int64(0)
if len(fees) > i {
fee = int64(fees[i])
} else if sc.Fee != 0 {
fee = int64(sc.Fee)
} else {
fee = 3000
}
pool := uniswap.CalculatePoolAddress(sc.Factory, tokens[i], tokens[i+1], fee)
pools = append(pools, pool)
if sc.PoolAddress == (common.Address{}) {
sc.PoolAddress = pool
}
}
sc.Pools = pools
}
func (sc *SwapCall) ensureV2PoolAddress() {
if sc.PoolAddress != (common.Address{}) && len(sc.Pools) > 0 {
return
}
if sc.Factory == (common.Address{}) || sc.TokenIn == (common.Address{}) || sc.TokenOut == (common.Address{}) {
return
}
tokens := sc.Path
if len(tokens) < 2 {
tokens = []common.Address{sc.TokenIn, sc.TokenOut}
}
pools := make([]common.Address, 0)
for i := 0; i+1 < len(tokens); i++ {
token0 := tokens[i]
token1 := tokens[i+1]
if token0.Big().Cmp(token1.Big()) > 0 {
token0, token1 = token1, token0
}
keccakInput := append(token0.Bytes(), token1.Bytes()...)
salt := crypto.Keccak256(keccakInput)
initCodeHash := common.HexToHash("0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f")
data := make([]byte, 0, 85)
data = append(data, 0xff)
data = append(data, sc.Factory.Bytes()...)
data = append(data, salt...)
data = append(data, initCodeHash.Bytes()...)
hash := crypto.Keccak256(data)
var addr common.Address
copy(addr[:], hash[12:])
pools = append(pools, addr)
if sc.PoolAddress == (common.Address{}) {
sc.PoolAddress = addr
}
}
sc.Pools = pools
}
func markNested(calls []*SwapCall) {
for _, c := range calls {
if c != nil {
c.nested = true
}
}
}
func contextProtocol(ctx *MulticallContext) string {
if ctx == nil {
return ""
}
return ctx.Protocol
}
func defaultProtocol(primary, fallback string) string {
if strings.TrimSpace(primary) != "" {
return primary
}
return fallback
}
func parseUniswapV3Path(path []byte) (common.Address, common.Address, uint32) {
if len(path) < 40 {
return common.Address{}, common.Address{}, 0
}
tokenIn := common.BytesToAddress(path[:20])
tokenOut := common.BytesToAddress(path[len(path)-20:])
fee := uint32(0)
if len(path) >= 23 {
feeBytes := path[20:23]
fee = uint32(feeBytes[0])<<16 | uint32(feeBytes[1])<<8 | uint32(feeBytes[2])
}
return tokenIn, tokenOut, fee
}
func parseUniswapV3FullPath(path []byte) ([]common.Address, []uint32) {
tokens := make([]common.Address, 0)
fees := make([]uint32, 0)
if len(path) < 20 {
return tokens, fees
}
cursor := 0
token := common.BytesToAddress(path[cursor : cursor+20])
tokens = append(tokens, token)
cursor += 20
for cursor+3+20 <= len(path) {
feeBytes := path[cursor : cursor+3]
fee := uint32(feeBytes[0])<<16 | uint32(feeBytes[1])<<8 | uint32(feeBytes[2])
fees = append(fees, fee)
cursor += 3
token = common.BytesToAddress(path[cursor : cursor+20])
tokens = append(tokens, token)
cursor += 20
}
return tokens, fees
}
func readInt(word []byte) (int, bool) {
if len(word) != 32 {
return 0, false
}
val := new(big.Int).SetBytes(word)
if !val.IsUint64() {
return 0, false
}
return safeConvertUint64ToInt(val.Uint64()), true
}
func readBytesAt(payload []byte, offset int) ([]byte, bool) {
cur := offset
for depth := 0; depth < 4; depth++ {
if cur < 0 || cur+32 > len(payload) {
return nil, false
}
word := new(big.Int).SetBytes(payload[cur : cur+32])
if !word.IsUint64() {
return nil, false
}
val := safeConvertUint64ToInt(word.Uint64())
// Attempt to treat the value as a pointer to the actual data block.
if val > 0 {
offsets := []int{}
candidate := val + cur
if candidate >= 0 {
offsets = append(offsets, candidate)
}
offsets = append(offsets, val)
followed := false
for _, ptr := range offsets {
if ptr < 0 || ptr+32 > len(payload) {
continue
}
lengthWord := new(big.Int).SetBytes(payload[ptr : ptr+32])
if !lengthWord.IsUint64() {
continue
}
size := safeConvertUint64ToInt(lengthWord.Uint64())
start := ptr + 32
end := start + size
if size >= 0 && end <= len(payload) {
return payload[start:end], true
}
cur = ptr
followed = true
break
}
if followed {
continue
}
}
// Fallback: treat the value as the length relative to the current offset.
size := val
start := cur + 32
end := start + size
if size >= 0 && end <= len(payload) {
return payload[start:end], true
}
if val == cur {
break
}
cur = val
}
return nil, false
}
func readBytesArrayAt(payload []byte, offset int) ([][]byte, bool) {
if offset < 0 || offset+32 > len(payload) {
return nil, false
}
length := new(big.Int).SetBytes(payload[offset : offset+32])
if !length.IsUint64() {
return nil, false
}
count := safeConvertUint64ToInt(length.Uint64())
result := make([][]byte, 0, count)
for i := 0; i < count; i++ {
entryOffsetWord := payload[offset+32+i*32 : offset+32+(i+1)*32]
entryOffset, ok := readInt(entryOffsetWord)
if !ok {
return nil, false
}
start := offset + entryOffset
entry, ok := readBytesAt(payload, start)
if !ok {
return nil, false
}
result = append(result, entry)
}
return result, true
}
func readAddressArray(payload []byte, offset int) ([]common.Address, bool) {
if offset < 0 || offset+32 > len(payload) {
return nil, false
}
length := new(big.Int).SetBytes(payload[offset : offset+32])
if !length.IsUint64() {
return nil, false
}
count := safeConvertUint64ToInt(length.Uint64())
start := offset + 32
end := start + count*32
if count <= 0 || end > len(payload) {
return nil, false
}
addresses := make([]common.Address, 0, count)
for i := 0; i < count; i++ {
word := payload[start+i*32 : start+(i+1)*32]
addresses = append(addresses, common.BytesToAddress(word[12:]))
}
return addresses, true
}
func (sc *SwapCall) String() string {
return fmt.Sprintf("%s %s->%s selector=%s", sc.Protocol, sc.TokenIn.Hex(), sc.TokenOut.Hex(), sc.Selector)
}

View File

@@ -0,0 +1,175 @@
package calldata
import (
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
type payloadFixture struct {
Protocol string `json:"protocol"`
Function string `json:"function"`
FunctionSig string `json:"function_sig"`
InputData string `json:"input_data"`
}
func loadPayload(t *testing.T, name string) payloadFixture {
t.Helper()
fullPath := filepath.Join("testdata", "payloads", name)
raw, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("failed to read payload fixture %s: %v", name, err)
}
var pf payloadFixture
if err := json.Unmarshal(raw, &pf); err != nil {
t.Fatalf("failed to unmarshal payload %s: %v", name, err)
}
return pf
}
func decodeDirect(raw []byte, ctx *MulticallContext) []*SwapCall {
validator := getAddressValidator()
return decodeSwapCallRecursive(raw, ctx, validator, 0)
}
func TestCapturedPayloadDecoding(t *testing.T) {
cases := []struct {
name string
fixture string
expectSwaps int
expectSelector string
tokenIn string
tokenOut string
verifyProtocol string
isMulticall bool
}{
{
name: "uniswap_v3_exact_output_single",
fixture: "uniswapv3_exact_output_single.json",
expectSwaps: 1,
expectSelector: "db3e2198",
tokenIn: "0xff970a61a04b1ca14834a43f5de4533ebddb5cc8",
tokenOut: "0x912ce59144191c1204e64559fe8253a0e49e6548",
verifyProtocol: "UniswapV3",
},
{
name: "uniswap_v3_exact_input_single",
fixture: "uniswapv3_exact_input_single.json",
expectSwaps: 1,
expectSelector: "414bf389",
tokenIn: "0x82af49447d8a07e3bd95bd0d56f35241523fbab1",
tokenOut: "0x440017a1b021006d556d7fc06a54c32e42eb745b",
verifyProtocol: "UniswapV3",
},
{
name: "uniswap_v2_swap_exact_tokens",
fixture: "uniswapv2_exact_tokens.json",
expectSwaps: 1,
expectSelector: "38ed1739",
tokenIn: "0x03f6921f6e948016631ce796331294d5a863a9ee",
tokenOut: "0xdcc9691793633176acf5cfdc1a658cb3b982e2fb",
verifyProtocol: "UniswapV2",
},
{
name: "multicall_without_recognised_swaps",
fixture: "multicall_uniswap.json",
expectSwaps: 0,
isMulticall: true,
},
{
name: "uniswap_v3_decrease_liquidity",
fixture: "uniswapv3_decrease_liquidity.json",
expectSwaps: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload := loadPayload(t, tc.fixture)
raw, err := hex.DecodeString(strings.TrimPrefix(payload.InputData, "0x"))
if err != nil {
t.Fatalf("failed to decode input data: %v", err)
}
if len(raw) < 4 {
t.Fatalf("payload too short: %d bytes", len(raw))
}
ctx := &MulticallContext{Protocol: payload.Protocol}
var swaps []*SwapCall
if tc.isMulticall {
swaps, err = DecodeSwapCallsFromMulticall(raw[4:], ctx)
if err != nil {
t.Fatalf("multicall decode failed: %v", err)
}
} else {
swaps = decodeDirect(raw, ctx)
}
if len(swaps) != tc.expectSwaps {
selector := strings.ToLower(hex.EncodeToString(raw[:4]))
if !tc.isMulticall {
// attempt direct decode based on selector for diagnostics
var diag *SwapCall
switch selector {
case "414bf389":
diag = decodeExactInputSingle(raw[4:], ctx)
case "db3e2198":
diag = decodeExactInput(raw[4:], ctx)
case "38ed1739":
diag = decodeUniswapV2Swap(selector, raw[4:], ctx)
}
if diag != nil {
t.Fatalf("expected %d swaps, got %d (selector=%s, diag=%s)", tc.expectSwaps, len(swaps), selector, diag.String())
}
}
t.Fatalf("expected %d swaps, got %d (selector=%s)", tc.expectSwaps, len(swaps), selector)
}
if tc.expectSwaps == 0 {
return
}
swap := swaps[0]
if !selectorsMatch(swap.Selector, tc.expectSelector) {
t.Fatalf("expected selector %s, got %s", tc.expectSelector, swap.Selector)
}
if !strings.EqualFold(swap.TokenIn.Hex(), tc.tokenIn) {
t.Fatalf("expected tokenIn %s, got %s", tc.tokenIn, swap.TokenIn.Hex())
}
if !strings.EqualFold(swap.TokenOut.Hex(), tc.tokenOut) {
t.Fatalf("expected tokenOut %s, got %s", tc.tokenOut, swap.TokenOut.Hex())
}
if tc.verifyProtocol != "" && !strings.EqualFold(swap.Protocol, tc.verifyProtocol) {
t.Fatalf("expected protocol %s, got %s", tc.verifyProtocol, swap.Protocol)
}
if selectorEquals(tc.expectSelector, "db3e2198") {
if swap.AmountOut == nil || swap.AmountOut.Sign() == 0 {
t.Fatalf("expected non-zero amountOut for exactOutputSingle")
}
}
if selectorEquals(tc.expectSelector, "414bf389") || selectorEquals(tc.expectSelector, "38ed1739") {
if swap.AmountIn == nil || swap.AmountIn.Sign() == 0 {
t.Fatalf("expected non-zero amountIn for selector %s", tc.expectSelector)
}
}
})
}
}
func normalizeSelectorHex(sel string) string {
s := strings.TrimSpace(strings.ToLower(sel))
return strings.TrimPrefix(s, "0x")
}
func selectorEquals(a, b string) bool {
return normalizeSelectorHex(a) == normalizeSelectorHex(b)
}
func selectorsMatch(actual, expected string) bool {
return selectorEquals(actual, expected)
}

View File

@@ -0,0 +1,12 @@
{
"block_number": "",
"contract_name": "TraderJoeRouter",
"from": "0x3855808a7f42dbaebacf07291e0ae0a7ed692ecb",
"function": "multicall",
"function_sig": "0xac9650d8",
"hash": "0xaf6228fcef1fa34dafd4e8d6e359b845e052a1a6597c88c1c2c94045c6140f9f",
"input_data": "0xac9650d800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000447d39aaf100000000000000000000000031ef83a530fde1b38ee9a18093a333d8bbbc40d50000000000000000000000000000000000000000000000000002d407c9f880c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000344f59c48eb0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000098d7803aea152ff1009f000000000000000000000000000000000000000000000000000000000000000012f0569e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000031a27787e450000000000000000000000000000000000000000000000000002d407c9f880c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000003855808a7f42dbaebacf07291e0ae0a7ed692ecb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ff00000000000000000000000000000000000001000000000000000000000000de967676db7b1ccdba2bd94b01b5b19de4b563e4000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"protocol": "Multicall",
"to": "0x87d66368cd08a7ca42252f5ab44b2fb6d1fb8d15",
"value": "796079871787200"
}

View File

@@ -0,0 +1,12 @@
{
"block_number": "",
"contract_name": "UniswapV2Router02",
"from": "0xba7a224852693f9a1695334105c2e63159eb2311",
"function": "swapExactTokensForTokens",
"function_sig": "0x38ed1739",
"hash": "0xee2af12f5e40c87e7452f70ef439bcf22663a726cc1bdf9f0def2e0f33f72320",
"input_data": "0x38ed17390000000000000000000000000000000000000000000000053220a269ab55c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000ba7a224852693f9a1695334105c2e63159eb23110000000000000000000000000000000000000000000000000000000068ed12b5000000000000000000000000000000000000000000000000000000000000000200000000000000000000000003f6921f6e948016631ce796331294d5a863a9ee000000000000000000000000dcc9691793633176acf5cfdc1a658cb3b982e2fb",
"protocol": "UniswapV2",
"to": "0x4752ba5dbc23f44d87826276bf6fd6b1c372ad24",
"value": "0"
}

View File

@@ -0,0 +1,12 @@
{
"block_number": "",
"contract_name": "UniswapV3PositionManager",
"from": "0xe5e58921dfa6602792e3f5624e91d291c01dc135",
"function": "decreaseLiquidity",
"function_sig": "0x0c49ccbe",
"hash": "0x5992966aa1cc733367aa0a00e2b3098b6d0d6b88835affbc43e02e1101ac4b8b",
"input_data": "0x0c49ccbe00000000000000000000000000000000000000000000000000000000004c1f2c00000000000000000000000000000000000000000000000000000001e20e0177000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068ed105b",
"protocol": "UniswapV3",
"to": "0xc36442b4a4522e871399cd717abdd847ab11fe88",
"value": "0"
}

View File

@@ -0,0 +1,12 @@
{
"block_number": "",
"contract_name": "UniswapV3Router",
"from": "0x196beae17c9577256a4c20d72a3c01cae5d00e9e",
"function": "exactInputSingle",
"function_sig": "0x414bf389",
"hash": "0xd39c510f44355cca3b7ee947b9697ef95df8921c8a90b3639572382092afe90c",
"input_data": "0x414bf38900000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000440017a1b021006d556d7fc06a54c32e42eb745b0000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000196beae17c9577256a4c20d72a3c01cae5d00e9e0000000000000000000000000000000000000000000000000000000068ed0e0e0000000000000000000000000000000000000000000000000024a689f2b2e3f0000000000000000000000000000000000000000000000011470476b682d78b380000000000000000000000000000000000000000000000000000000000000000",
"protocol": "UniswapV3",
"to": "0xe592427a0aece92de3edee1f18e0157c05861564",
"value": "0"
}

View File

@@ -0,0 +1,12 @@
{
"block_number": "",
"contract_name": "UniswapV3Router",
"from": "0x8cc6ab9ac1d1b7c5f6fc33f767aded9a305744e3",
"function": "exactOutputSingle",
"function_sig": "0xdb3e2198",
"hash": "0x69bad4eca82a4e139aad810777dc72faf5414e338b0a1b648e8472cd4904f93e",
"input_data": "0xdb3e2198000000000000000000000000ff970a61a04b1ca14834a43f5de4533ebddb5cc8000000000000000000000000912ce59144191c1204e64559fe8253a0e49e654800000000000000000000000000000000000000000000000000000000000001f40000000000000000000000008cc6ab9ac1d1b7c5f6fc33f767aded9a305744e30000000000000000000000000000000000000000000000000000000068ed0bbd00000000000000000000000000000000000000000000001b1ae4d6e2ef500000000000000000000000000000000000000000000000000000000000000a482cf40000000000000000000000000000000000000000000000000000000000000000",
"protocol": "UniswapV3",
"to": "0xe592427a0aece92de3edee1f18e0157c05861564",
"value": "0"
}