diff --git a/pkg/discovery/uniswap_v2_pools.go b/pkg/discovery/uniswap_v2_pools.go new file mode 100644 index 0000000..7aa063d --- /dev/null +++ b/pkg/discovery/uniswap_v2_pools.go @@ -0,0 +1,245 @@ +package discovery + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + "coppertone.tech/fraktal/mev-bot/pkg/cache" + "coppertone.tech/fraktal/mev-bot/pkg/types" +) + +// UniswapV2FactoryAddress is the Uniswap V2 factory on Arbitrum +var UniswapV2FactoryAddress = common.HexToAddress("0xf1D7CC64Fb4452F05c498126312eBE29f30Fbcf9") + +// Well-known token addresses on Arbitrum for discovering major pools +var ( + WETH = common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + USDC = common.HexToAddress("0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8") + USDT = common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9") + ARB = common.HexToAddress("0x912CE59144191C1204E64559FE8253a0e49E6548") + WBTC = common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f") + DAI = common.HexToAddress("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1") + LINK = common.HexToAddress("0xf97f4df75117a78c1A5a0DBb814Af92458539FB4") + UNI = common.HexToAddress("0xFa7F8980b0f1E64A2062791cc3b0871572f1F7f0") +) + +// MajorTokenPairs defines the most liquid trading pairs on Arbitrum +// These are the pairs most likely to have arbitrage opportunities +var MajorTokenPairs = [][2]common.Address{ + {WETH, USDC}, // Most liquid pair + {WETH, USDT}, // Major stablecoin pair + {WETH, ARB}, // Native token pair + {WETH, WBTC}, // BTC/ETH pair + {WETH, DAI}, // DAI stablecoin + {USDC, USDT}, // Stablecoin arbitrage + {USDC, DAI}, // Stablecoin arbitrage + {ARB, USDC}, // ARB trading + {WBTC, USDC}, // BTC trading + {LINK, USDC}, // LINK trading + {UNI, USDC}, // UNI trading +} + +// UniswapV2PoolDiscovery discovers and populates UniswapV2 pools +type UniswapV2PoolDiscovery struct { + client *ethclient.Client + poolCache cache.PoolCache + factoryAddr common.Address + factoryABI abi.ABI + pairABI abi.ABI +} + +// NewUniswapV2PoolDiscovery creates a new pool discovery instance +func NewUniswapV2PoolDiscovery(client *ethclient.Client, poolCache cache.PoolCache) (*UniswapV2PoolDiscovery, error) { + if client == nil { + return nil, fmt.Errorf("client cannot be nil") + } + if poolCache == nil { + return nil, fmt.Errorf("pool cache cannot be nil") + } + + // Define minimal factory ABI for getPair function + factoryABI, err := abi.JSON(strings.NewReader(`[{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"}],"name":"getPair","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"}]`)) + if err != nil { + return nil, fmt.Errorf("failed to parse factory ABI: %w", err) + } + + // Define minimal pair ABI for token0, token1, and getReserves + pairABI, err := abi.JSON(strings.NewReader(`[ + {"constant":true,"inputs":[],"name":"token0","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"}, + {"constant":true,"inputs":[],"name":"token1","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"}, + {"constant":true,"inputs":[],"name":"getReserves","outputs":[{"internalType":"uint112","name":"reserve0","type":"uint112"},{"internalType":"uint112","name":"reserve1","type":"uint112"},{"internalType":"uint32","name":"blockTimestampLast","type":"uint32"}],"payable":false,"stateMutability":"view","type":"function"} + ]`)) + if err != nil { + return nil, fmt.Errorf("failed to parse pair ABI: %w", err) + } + + return &UniswapV2PoolDiscovery{ + client: client, + poolCache: poolCache, + factoryAddr: UniswapV2FactoryAddress, + factoryABI: factoryABI, + pairABI: pairABI, + }, nil +} + +// DiscoverMajorPools discovers and caches the major UniswapV2 pools on Arbitrum +// This is a quick method to populate the cache with the most liquid pools for MVP +func (d *UniswapV2PoolDiscovery) DiscoverMajorPools(ctx context.Context) (int, error) { + discovered := 0 + + for _, pair := range MajorTokenPairs { + token0 := pair[0] + token1 := pair[1] + + // Get pool address from factory + poolAddr, err := d.getPairAddress(ctx, token0, token1) + if err != nil { + continue // Pool doesn't exist, skip + } + + // Skip if pool is zero address (doesn't exist) + if poolAddr == (common.Address{}) { + continue + } + + // Fetch pool details + poolInfo, err := d.fetchPoolInfo(ctx, poolAddr, token0, token1) + if err != nil { + continue // Failed to fetch, skip + } + + // Add to cache + if err := d.poolCache.Add(ctx, poolInfo); err != nil { + continue // Already exists or failed to add + } + + discovered++ + } + + return discovered, nil +} + +// getPairAddress queries the UniswapV2 factory for a pool address +func (d *UniswapV2PoolDiscovery) getPairAddress(ctx context.Context, token0, token1 common.Address) (common.Address, error) { + // Pack the function call + data, err := d.factoryABI.Pack("getPair", token0, token1) + if err != nil { + return common.Address{}, fmt.Errorf("failed to pack getPair call: %w", err) + } + + // Call the contract + msg := ethereum.CallMsg{ + To: &d.factoryAddr, + Data: data, + } + result, err := d.client.CallContract(ctx, msg, nil) + if err != nil { + return common.Address{}, fmt.Errorf("failed to call getPair: %w", err) + } + + // Unpack the result + var pairAddr common.Address + err = d.factoryABI.UnpackIntoInterface(&pairAddr, "getPair", result) + if err != nil { + return common.Address{}, fmt.Errorf("failed to unpack getPair result: %w", err) + } + + return pairAddr, nil +} + +// fetchPoolInfo fetches detailed information about a pool +func (d *UniswapV2PoolDiscovery) fetchPoolInfo(ctx context.Context, poolAddr, expectedToken0, expectedToken1 common.Address) (*types.PoolInfo, error) { + // Fetch token0 from pool + token0Data, err := d.pairABI.Pack("token0") + if err != nil { + return nil, err + } + msg0 := ethereum.CallMsg{ + To: &poolAddr, + Data: token0Data, + } + token0Result, err := d.client.CallContract(ctx, msg0, nil) + if err != nil { + return nil, err + } + var token0 common.Address + d.pairABI.UnpackIntoInterface(&token0, "token0", token0Result) + + // Fetch token1 from pool + token1Data, err := d.pairABI.Pack("token1") + if err != nil { + return nil, err + } + msg1 := ethereum.CallMsg{ + To: &poolAddr, + Data: token1Data, + } + token1Result, err := d.client.CallContract(ctx, msg1, nil) + if err != nil { + return nil, err + } + var token1 common.Address + d.pairABI.UnpackIntoInterface(&token1, "token1", token1Result) + + // Fetch reserves + reservesData, err := d.pairABI.Pack("getReserves") + if err != nil { + return nil, err + } + msgReserves := ethereum.CallMsg{ + To: &poolAddr, + Data: reservesData, + } + reservesResult, err := d.client.CallContract(ctx, msgReserves, nil) + if err != nil { + return nil, err + } + + // Unpack reserves + type Reserves struct { + Reserve0 *big.Int + Reserve1 *big.Int + BlockTimestampLast uint32 + } + var reserves Reserves + d.pairABI.UnpackIntoInterface(&reserves, "getReserves", reservesResult) + + // Get decimals (hardcoded for known tokens for MVP speed) + token0Decimals := getTokenDecimals(token0) + token1Decimals := getTokenDecimals(token1) + + // Calculate total liquidity in USD (simplified) + liquidity := new(big.Int).Add(reserves.Reserve0, reserves.Reserve1) + + poolInfo := &types.PoolInfo{ + Address: poolAddr, + Protocol: types.ProtocolUniswapV2, + Token0: token0, + Token1: token1, + Token0Decimals: token0Decimals, + Token1Decimals: token1Decimals, + Liquidity: liquidity, + } + + return poolInfo, nil +} + +// getTokenDecimals returns the decimals for known tokens on Arbitrum +// For MVP, we hardcode the major tokens. Production would query the token contract. +func getTokenDecimals(token common.Address) uint8 { + switch token { + case WETH, ARB, DAI, LINK, UNI, WBTC: + return 18 + case USDC, USDT: + return 6 + default: + return 18 // Default assumption + } +} diff --git a/pkg/discovery/uniswap_v2_pools_test.go b/pkg/discovery/uniswap_v2_pools_test.go new file mode 100644 index 0000000..e1fc937 --- /dev/null +++ b/pkg/discovery/uniswap_v2_pools_test.go @@ -0,0 +1,170 @@ +package discovery + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + + "coppertone.tech/fraktal/mev-bot/pkg/cache" +) + +// TestNewUniswapV2PoolDiscovery tests the constructor for pool discovery +func TestNewUniswapV2PoolDiscovery(t *testing.T) { + tests := []struct { + name string + client interface{} // Using interface{} since we can't easily mock ethclient + poolCache cache.PoolCache + wantError bool + }{ + { + name: "nil client should error", + client: nil, + poolCache: cache.NewPoolCache(), + wantError: true, + }, + { + name: "nil cache should error", + client: "mock", // Not nil but not a real client + poolCache: nil, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // We can't actually create a real client here without RPC endpoint + // This test mainly validates the nil checks + // Real integration tests would use actual Arbitrum RPC + if tt.client == nil { + _, err := NewUniswapV2PoolDiscovery(nil, tt.poolCache) + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + } + }) + } +} + +// TestMajorTokenPairs validates that the major token pairs are correctly defined +func TestMajorTokenPairs(t *testing.T) { + // Verify we have the expected pairs + assert.NotEmpty(t, MajorTokenPairs, "should have major token pairs defined") + assert.GreaterOrEqual(t, len(MajorTokenPairs), 10, "should have at least 10 major pairs") + + // Verify WETH/USDC pair exists (most liquid pair) + foundWETHUSDC := false + for _, pair := range MajorTokenPairs { + if (pair[0] == WETH && pair[1] == USDC) || (pair[0] == USDC && pair[1] == WETH) { + foundWETHUSDC = true + break + } + } + assert.True(t, foundWETHUSDC, "should include WETH/USDC pair") +} + +// TestGetTokenDecimals validates the token decimals helper +func TestGetTokenDecimals(t *testing.T) { + tests := []struct { + name string + token common.Address + expected uint8 + }{ + { + name: "WETH should be 18 decimals", + token: WETH, + expected: 18, + }, + { + name: "USDC should be 6 decimals", + token: USDC, + expected: 6, + }, + { + name: "USDT should be 6 decimals", + token: USDT, + expected: 6, + }, + { + name: "ARB should be 18 decimals", + token: ARB, + expected: 18, + }, + { + name: "WBTC should be 18 decimals (Arbitrum wrapped BTC)", + token: WBTC, + expected: 18, + }, + { + name: "unknown token should default to 18", + token: common.HexToAddress("0x0000000000000000000000000000000000000001"), + expected: 18, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decimals := getTokenDecimals(tt.token) + assert.Equal(t, tt.expected, decimals) + }) + } +} + +// TestUniswapV2FactoryAddress validates the factory address is set +func TestUniswapV2FactoryAddress(t *testing.T) { + // Verify factory address is not zero + assert.NotEqual(t, common.Address{}, UniswapV2FactoryAddress, "factory address should not be zero") + + // Verify it matches the known Arbitrum UniswapV2 factory + expectedFactory := common.HexToAddress("0xf1D7CC64Fb4452F05c498126312eBE29f30Fbcf9") + assert.Equal(t, expectedFactory, UniswapV2FactoryAddress, "should match Arbitrum UniswapV2 factory") +} + +// TestWellKnownTokenAddresses validates all token addresses are non-zero +func TestWellKnownTokenAddresses(t *testing.T) { + tokens := map[string]common.Address{ + "WETH": WETH, + "USDC": USDC, + "USDT": USDT, + "ARB": ARB, + "WBTC": WBTC, + "DAI": DAI, + "LINK": LINK, + "UNI": UNI, + } + + for name, addr := range tokens { + t.Run(name, func(t *testing.T) { + assert.NotEqual(t, common.Address{}, addr, "%s address should not be zero", name) + }) + } +} + +// NOTE: Integration tests that actually call Arbitrum RPC would go in a separate file +// marked with build tags (e.g., // +build integration) so they don't run in CI +// without proper RPC configuration. Example: +// +// // +build integration +// +// func TestDiscoverMajorPools_Integration(t *testing.T) { +// // This would require ARBITRUM_RPC_URL environment variable +// rpcURL := os.Getenv("ARBITRUM_RPC_URL") +// if rpcURL == "" { +// t.Skip("ARBITRUM_RPC_URL not set") +// } +// +// client, err := ethclient.Dial(rpcURL) +// require.NoError(t, err) +// defer client.Close() +// +// poolCache := cache.NewPoolCache() +// discovery, err := NewUniswapV2PoolDiscovery(client, poolCache) +// require.NoError(t, err) +// +// ctx := context.Background() +// count, err := discovery.DiscoverMajorPools(ctx) +// assert.NoError(t, err) +// assert.Greater(t, count, 0, "should discover at least one pool") +// }