diff --git a/pkg/execution/curve_encoder_test.go b/pkg/execution/curve_encoder_test.go new file mode 100644 index 0000000..045d6a0 --- /dev/null +++ b/pkg/execution/curve_encoder_test.go @@ -0,0 +1,421 @@ +package execution + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCurveEncoder(t *testing.T) { + encoder := NewCurveEncoder() + assert.NotNil(t, encoder) +} + +func TestCurveEncoder_EncodeSwap(t *testing.T) { + encoder := NewCurveEncoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + ) + + require.NoError(t, err) + assert.Equal(t, poolAddress, to) + assert.NotEmpty(t, data) + + // Check method ID (first 4 bytes) + // exchange(int128,int128,uint256,uint256) + assert.Len(t, data, 4+4*32) // methodID + 4 parameters +} + +func TestCurveEncoder_EncodeExchangeUnderlying(t *testing.T) { + encoder := NewCurveEncoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + + to, data, err := encoder.EncodeExchangeUnderlying( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + ) + + require.NoError(t, err) + assert.Equal(t, poolAddress, to) + assert.NotEmpty(t, data) + + // Check method ID + // exchange_underlying(int128,int128,uint256,uint256) + assert.Len(t, data, 4+4*32) +} + +func TestCurveEncoder_EncodeDynamicExchange(t *testing.T) { + encoder := NewCurveEncoder() + + i := big.NewInt(0) + j := big.NewInt(1) + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + + to, data, err := encoder.EncodeDynamicExchange( + i, + j, + amountIn, + minAmountOut, + poolAddress, + ) + + require.NoError(t, err) + assert.Equal(t, poolAddress, to) + assert.NotEmpty(t, data) + + // Check method ID + // exchange(uint256,uint256,uint256,uint256) + assert.Len(t, data, 4+4*32) +} + +func TestCurveEncoder_EncodeDynamicExchange_HighIndices(t *testing.T) { + encoder := NewCurveEncoder() + + i := big.NewInt(2) + j := big.NewInt(3) + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + + to, data, err := encoder.EncodeDynamicExchange( + i, + j, + amountIn, + minAmountOut, + poolAddress, + ) + + require.NoError(t, err) + assert.Equal(t, poolAddress, to) + assert.NotEmpty(t, data) +} + +func TestCurveEncoder_EncodeGetDy(t *testing.T) { + encoder := NewCurveEncoder() + + i := big.NewInt(0) + j := big.NewInt(1) + amountIn := big.NewInt(1e18) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + + to, data, err := encoder.EncodeGetDy( + i, + j, + amountIn, + poolAddress, + ) + + require.NoError(t, err) + assert.Equal(t, poolAddress, to) + assert.NotEmpty(t, data) + + // Check method ID + // get_dy(int128,int128,uint256) + assert.Len(t, data, 4+3*32) +} + +func TestCurveEncoder_EncodeCoinIndices(t *testing.T) { + encoder := NewCurveEncoder() + + tokenAddress := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + + to, data, err := encoder.EncodeCoinIndices( + tokenAddress, + poolAddress, + ) + + require.NoError(t, err) + assert.Equal(t, poolAddress, to) + assert.NotEmpty(t, data) + + // Check method ID + // coins(uint256) + assert.Len(t, data, 4+32) +} + +func TestCurveEncoder_GetCoinIndex(t *testing.T) { + encoder := NewCurveEncoder() + + tests := []struct { + name string + tokenAddress common.Address + poolCoins []common.Address + expectedIndex int + expectError bool + }{ + { + name: "First coin", + tokenAddress: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + poolCoins: []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + }, + expectedIndex: 0, + expectError: false, + }, + { + name: "Second coin", + tokenAddress: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + poolCoins: []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + }, + expectedIndex: 1, + expectError: false, + }, + { + name: "Third coin", + tokenAddress: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + poolCoins: []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + }, + expectedIndex: 2, + expectError: false, + }, + { + name: "Token not in pool", + tokenAddress: common.HexToAddress("0x0000000000000000000000000000000000000099"), + poolCoins: []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + }, + expectedIndex: -1, + expectError: true, + }, + { + name: "Empty pool", + tokenAddress: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + poolCoins: []common.Address{}, + expectedIndex: -1, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + index, err := encoder.GetCoinIndex(tt.tokenAddress, tt.poolCoins) + + if tt.expectError { + assert.Error(t, err) + assert.Equal(t, tt.expectedIndex, index) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedIndex, index) + } + }) + } +} + +func TestCurveEncoder_ZeroAddresses(t *testing.T) { + encoder := NewCurveEncoder() + + tokenIn := common.Address{} + tokenOut := common.Address{} + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.Address{} + recipient := common.Address{} + + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestCurveEncoder_ZeroAmounts(t *testing.T) { + encoder := NewCurveEncoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountIn := big.NewInt(0) + minAmountOut := big.NewInt(0) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestCurveEncoder_LargeAmounts(t *testing.T) { + encoder := NewCurveEncoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + + // Max uint256 + amountIn := new(big.Int) + amountIn.SetString("115792089237316195423570985008687907853269984665640564039457584007913129639935", 10) + minAmountOut := new(big.Int) + minAmountOut.SetString("115792089237316195423570985008687907853269984665640564039457584007913129639935", 10) + + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestCurveEncoder_LargeIndices(t *testing.T) { + encoder := NewCurveEncoder() + + // Test with large indices (for pools with many coins) + i := big.NewInt(7) + j := big.NewInt(15) + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + + to, data, err := encoder.EncodeDynamicExchange( + i, + j, + amountIn, + minAmountOut, + poolAddress, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestCurveEncoder_NegativeIndices(t *testing.T) { + encoder := NewCurveEncoder() + + // Negative indices (should be encoded as int128) + i := big.NewInt(-1) + j := big.NewInt(-2) + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + + to, data, err := encoder.EncodeDynamicExchange( + i, + j, + amountIn, + minAmountOut, + poolAddress, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestCurveEncoder_GetCoinIndex_MultipleTokens(t *testing.T) { + encoder := NewCurveEncoder() + + // Test with a 4-coin pool (common for Curve) + poolCoins := []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), // WETH + common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), // USDC + common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"), // USDT + common.HexToAddress("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"), // DAI + } + + // Test each token + for i, token := range poolCoins { + index, err := encoder.GetCoinIndex(token, poolCoins) + require.NoError(t, err) + assert.Equal(t, i, index) + } +} + +// Benchmark tests +func BenchmarkCurveEncoder_EncodeSwap(b *testing.B) { + encoder := NewCurveEncoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + ) + } +} + +func BenchmarkCurveEncoder_GetCoinIndex(b *testing.B) { + encoder := NewCurveEncoder() + + tokenAddress := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + poolCoins := []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"), + common.HexToAddress("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"), + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = encoder.GetCoinIndex(tokenAddress, poolCoins) + } +} diff --git a/pkg/execution/executor_test.go b/pkg/execution/executor_test.go new file mode 100644 index 0000000..e1d399a --- /dev/null +++ b/pkg/execution/executor_test.go @@ -0,0 +1,567 @@ +package execution + +import ( + "context" + "log/slog" + "math/big" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/your-org/mev-bot/pkg/arbitrage" + mevtypes "github.com/your-org/mev-bot/pkg/types" +) + +func TestDefaultExecutorConfig(t *testing.T) { + config := DefaultExecutorConfig() + + assert.NotNil(t, config) + assert.Equal(t, uint64(1), config.ConfirmationBlocks) + assert.Equal(t, 5*time.Minute, config.TimeoutPerTx) + assert.Equal(t, 3, config.MaxRetries) + assert.Equal(t, uint64(2), config.NonceMargin) + assert.Equal(t, "fast", config.GasPriceStrategy) +} + +func TestExecutor_getNextNonce(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + currentNonce: 10, + nonceCache: make(map[uint64]*PendingTransaction), + } + + // Get first nonce + nonce1 := executor.getNextNonce() + assert.Equal(t, uint64(10), nonce1) + assert.Equal(t, uint64(11), executor.currentNonce) + + // Get second nonce + nonce2 := executor.getNextNonce() + assert.Equal(t, uint64(11), nonce2) + assert.Equal(t, uint64(12), executor.currentNonce) +} + +func TestExecutor_releaseNonce(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + currentNonce: 10, + nonceCache: make(map[uint64]*PendingTransaction), + } + + // Release current nonce - 1 (should work) + executor.releaseNonce(9) + assert.Equal(t, uint64(9), executor.currentNonce) + + // Release older nonce (should not work) + executor.currentNonce = 10 + executor.releaseNonce(5) + assert.Equal(t, uint64(10), executor.currentNonce) // Should not change +} + +func TestExecutor_trackPendingTransaction(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + nonceCache: make(map[uint64]*PendingTransaction), + } + + hash := common.HexToHash("0x123") + nonce := uint64(5) + opp := &arbitrage.Opportunity{ + ID: "test-opp", + InputAmount: big.NewInt(1e18), + } + + executor.trackPendingTransaction(nonce, hash, opp) + + // Check transaction is tracked + pending, exists := executor.nonceCache[nonce] + assert.True(t, exists) + assert.NotNil(t, pending) + assert.Equal(t, hash, pending.Hash) + assert.Equal(t, nonce, pending.Nonce) + assert.Equal(t, opp, pending.Opportunity) + assert.False(t, pending.Confirmed) + assert.False(t, pending.Failed) +} + +func TestExecutor_GetPendingTransactions(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + nonceCache: make(map[uint64]*PendingTransaction), + } + + // Add pending transactions + executor.nonceCache[1] = &PendingTransaction{ + Hash: common.HexToHash("0x01"), + Confirmed: false, + Failed: false, + } + executor.nonceCache[2] = &PendingTransaction{ + Hash: common.HexToHash("0x02"), + Confirmed: true, // Already confirmed + Failed: false, + } + executor.nonceCache[3] = &PendingTransaction{ + Hash: common.HexToHash("0x03"), + Confirmed: false, + Failed: false, + } + + pending := executor.GetPendingTransactions() + + // Should only return unconfirmed, non-failed transactions + assert.Len(t, pending, 2) +} + +func TestExecutor_Stop(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + stopCh: make(chan struct{}), + stopped: false, + } + + executor.Stop() + + assert.True(t, executor.stopped) + + // Calling Stop again should not panic + executor.Stop() + assert.True(t, executor.stopped) +} + +func TestPendingTransaction_Fields(t *testing.T) { + hash := common.HexToHash("0x123") + nonce := uint64(5) + opp := &arbitrage.Opportunity{ + ID: "test-opp", + } + submittedAt := time.Now() + + pending := &PendingTransaction{ + Hash: hash, + Nonce: nonce, + Opportunity: opp, + SubmittedAt: submittedAt, + LastChecked: submittedAt, + Confirmed: false, + Failed: false, + FailReason: "", + Receipt: nil, + Retries: 0, + } + + assert.Equal(t, hash, pending.Hash) + assert.Equal(t, nonce, pending.Nonce) + assert.Equal(t, opp, pending.Opportunity) + assert.Equal(t, submittedAt, pending.SubmittedAt) + assert.False(t, pending.Confirmed) + assert.False(t, pending.Failed) + assert.Equal(t, 0, pending.Retries) +} + +func TestExecutionResult_Success(t *testing.T) { + hash := common.HexToHash("0x123") + actualProfit := big.NewInt(0.1e18) + gasCost := big.NewInt(0.01e18) + duration := 5 * time.Second + + result := &ExecutionResult{ + Success: true, + TxHash: hash, + Receipt: nil, + ActualProfit: actualProfit, + GasCost: gasCost, + Error: nil, + Duration: duration, + } + + assert.True(t, result.Success) + assert.Equal(t, hash, result.TxHash) + assert.Equal(t, actualProfit, result.ActualProfit) + assert.Equal(t, gasCost, result.GasCost) + assert.Nil(t, result.Error) + assert.Equal(t, duration, result.Duration) +} + +func TestExecutionResult_Failure(t *testing.T) { + hash := common.HexToHash("0x123") + err := assert.AnError + duration := 2 * time.Second + + result := &ExecutionResult{ + Success: false, + TxHash: hash, + Receipt: nil, + ActualProfit: nil, + GasCost: nil, + Error: err, + Duration: duration, + } + + assert.False(t, result.Success) + assert.Equal(t, hash, result.TxHash) + assert.NotNil(t, result.Error) + assert.Equal(t, duration, result.Duration) +} + +func TestExecutorConfig_RPC(t *testing.T) { + config := &ExecutorConfig{ + PrivateKey: []byte{0x01, 0x02, 0x03}, + WalletAddress: common.HexToAddress("0x123"), + RPCEndpoint: "http://localhost:8545", + PrivateRPCEndpoint: "http://flashbots:8545", + UsePrivateRPC: true, + } + + assert.NotEmpty(t, config.PrivateKey) + assert.NotEmpty(t, config.WalletAddress) + assert.Equal(t, "http://localhost:8545", config.RPCEndpoint) + assert.Equal(t, "http://flashbots:8545", config.PrivateRPCEndpoint) + assert.True(t, config.UsePrivateRPC) +} + +func TestExecutorConfig_GasStrategy(t *testing.T) { + tests := []struct { + name string + strategy string + multiplier float64 + maxIncrease float64 + }{ + { + name: "Fast strategy", + strategy: "fast", + multiplier: 1.2, + maxIncrease: 1.5, + }, + { + name: "Market strategy", + strategy: "market", + multiplier: 1.0, + maxIncrease: 1.3, + }, + { + name: "Aggressive strategy", + strategy: "aggressive", + multiplier: 1.5, + maxIncrease: 2.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &ExecutorConfig{ + GasPriceStrategy: tt.strategy, + GasPriceMultiplier: tt.multiplier, + MaxGasPriceIncrement: tt.maxIncrease, + } + + assert.Equal(t, tt.strategy, config.GasPriceStrategy) + assert.Equal(t, tt.multiplier, config.GasPriceMultiplier) + assert.Equal(t, tt.maxIncrease, config.MaxGasPriceIncrement) + }) + } +} + +func TestExecutor_calculateActualProfit(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + } + + // Create mock receipt + receipt := &types.Receipt{ + GasUsed: 150000, + EffectiveGasPrice: big.NewInt(50e9), // 50 gwei + } + + opp := &arbitrage.Opportunity{ + GrossProfit: big.NewInt(0.2e18), // 0.2 ETH + } + + actualProfit := executor.calculateActualProfit(receipt, opp) + + // Gas cost = 150000 * 50e9 = 0.0075 ETH + // Actual profit = 0.2 - 0.0075 = 0.1925 ETH + expectedProfit := big.NewInt(192500000000000000) // 0.1925 ETH + assert.Equal(t, expectedProfit, actualProfit) +} + +func TestExecutor_NonceManagement_Concurrent(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + currentNonce: 10, + nonceCache: make(map[uint64]*PendingTransaction), + } + + // Simulate concurrent nonce requests + nonces := make(chan uint64, 10) + + for i := 0; i < 10; i++ { + go func() { + nonce := executor.getNextNonce() + nonces <- nonce + }() + } + + // Collect all nonces + receivedNonces := make(map[uint64]bool) + for i := 0; i < 10; i++ { + nonce := <-nonces + // Check for duplicates + assert.False(t, receivedNonces[nonce], "Duplicate nonce detected") + receivedNonces[nonce] = true + } + + // All nonces should be unique and sequential + assert.Len(t, receivedNonces, 10) + assert.Equal(t, uint64(20), executor.currentNonce) +} + +func TestExecutor_PendingTransaction_Timeout(t *testing.T) { + submittedAt := time.Now().Add(-10 * time.Minute) + + pending := &PendingTransaction{ + Hash: common.HexToHash("0x123"), + SubmittedAt: submittedAt, + LastChecked: time.Now(), + Confirmed: false, + Failed: false, + Retries: 0, + } + + timeout := 5 * time.Minute + isTimedOut := time.Since(pending.SubmittedAt) > timeout + + assert.True(t, isTimedOut) +} + +func TestExecutor_PendingTransaction_NotTimedOut(t *testing.T) { + submittedAt := time.Now().Add(-2 * time.Minute) + + pending := &PendingTransaction{ + Hash: common.HexToHash("0x123"), + SubmittedAt: submittedAt, + LastChecked: time.Now(), + Confirmed: false, + Failed: false, + Retries: 0, + } + + timeout := 5 * time.Minute + isTimedOut := time.Since(pending.SubmittedAt) > timeout + + assert.False(t, isTimedOut) +} + +func TestExecutor_PendingTransaction_MaxRetries(t *testing.T) { + pending := &PendingTransaction{ + Hash: common.HexToHash("0x123"), + Retries: 3, + } + + maxRetries := 3 + canRetry := pending.Retries < maxRetries + + assert.False(t, canRetry) +} + +func TestExecutor_PendingTransaction_CanRetry(t *testing.T) { + pending := &PendingTransaction{ + Hash: common.HexToHash("0x123"), + Retries: 1, + } + + maxRetries := 3 + canRetry := pending.Retries < maxRetries + + assert.True(t, canRetry) +} + +func TestExecutor_TransactionConfirmed(t *testing.T) { + pending := &PendingTransaction{ + Hash: common.HexToHash("0x123"), + Confirmed: true, + Failed: false, + Receipt: &types.Receipt{Status: types.ReceiptStatusSuccessful}, + } + + assert.True(t, pending.Confirmed) + assert.False(t, pending.Failed) + assert.NotNil(t, pending.Receipt) + assert.Equal(t, types.ReceiptStatusSuccessful, pending.Receipt.Status) +} + +func TestExecutor_TransactionFailed(t *testing.T) { + pending := &PendingTransaction{ + Hash: common.HexToHash("0x123"), + Confirmed: true, + Failed: true, + FailReason: "transaction reverted", + Receipt: &types.Receipt{Status: types.ReceiptStatusFailed}, + } + + assert.True(t, pending.Confirmed) + assert.True(t, pending.Failed) + assert.Equal(t, "transaction reverted", pending.FailReason) + assert.NotNil(t, pending.Receipt) + assert.Equal(t, types.ReceiptStatusFailed, pending.Receipt.Status) +} + +func TestExecutorConfig_Defaults(t *testing.T) { + config := DefaultExecutorConfig() + + // Test all default values + assert.Equal(t, uint64(1), config.ConfirmationBlocks) + assert.Equal(t, 5*time.Minute, config.TimeoutPerTx) + assert.Equal(t, 3, config.MaxRetries) + assert.Equal(t, 5*time.Second, config.RetryDelay) + assert.Equal(t, uint64(2), config.NonceMargin) + assert.Equal(t, "fast", config.GasPriceStrategy) + assert.Equal(t, float64(1.1), config.GasPriceMultiplier) + assert.Equal(t, float64(1.5), config.MaxGasPriceIncrement) + assert.Equal(t, 1*time.Second, config.MonitorInterval) + assert.Equal(t, 1*time.Minute, config.CleanupInterval) +} + +func TestExecutor_MultipleOpportunities(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + currentNonce: 10, + nonceCache: make(map[uint64]*PendingTransaction), + } + + // Track multiple opportunities + for i := 0; i < 5; i++ { + hash := common.HexToHash(string(rune(i))) + nonce := executor.getNextNonce() + opp := &arbitrage.Opportunity{ + ID: string(rune(i)), + } + + executor.trackPendingTransaction(nonce, hash, opp) + } + + // Check all are tracked + assert.Len(t, executor.nonceCache, 5) + assert.Equal(t, uint64(15), executor.currentNonce) + + // Get pending transactions + pending := executor.GetPendingTransactions() + assert.Len(t, pending, 5) +} + +func TestExecutor_CleanupOldTransactions(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + nonceCache: make(map[uint64]*PendingTransaction), + } + + // Add old confirmed transaction + oldTime := time.Now().Add(-2 * time.Hour) + executor.nonceCache[1] = &PendingTransaction{ + Hash: common.HexToHash("0x01"), + Confirmed: true, + LastChecked: oldTime, + } + + // Add recent transaction + executor.nonceCache[2] = &PendingTransaction{ + Hash: common.HexToHash("0x02"), + Confirmed: false, + LastChecked: time.Now(), + } + + // Simulate cleanup (cutoff = 1 hour) + cutoff := time.Now().Add(-1 * time.Hour) + for nonce, pending := range executor.nonceCache { + if (pending.Confirmed || pending.Failed) && pending.LastChecked.Before(cutoff) { + delete(executor.nonceCache, nonce) + } + } + + // Only recent transaction should remain + assert.Len(t, executor.nonceCache, 1) + _, exists := executor.nonceCache[2] + assert.True(t, exists) +} + +// Benchmark tests +func BenchmarkExecutor_getNextNonce(b *testing.B) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + currentNonce: 0, + nonceCache: make(map[uint64]*PendingTransaction), + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = executor.getNextNonce() + } +} + +func BenchmarkExecutor_trackPendingTransaction(b *testing.B) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + nonceCache: make(map[uint64]*PendingTransaction), + } + + hash := common.HexToHash("0x123") + opp := &arbitrage.Opportunity{ + ID: "test-opp", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + executor.trackPendingTransaction(uint64(i), hash, opp) + } +} + +func BenchmarkExecutor_GetPendingTransactions(b *testing.B) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + executor := &Executor{ + logger: logger, + nonceCache: make(map[uint64]*PendingTransaction), + } + + // Add 100 pending transactions + for i := 0; i < 100; i++ { + executor.nonceCache[uint64(i)] = &PendingTransaction{ + Hash: common.HexToHash(string(rune(i))), + Confirmed: false, + Failed: false, + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = executor.GetPendingTransactions() + } +} diff --git a/pkg/execution/flashloan_test.go b/pkg/execution/flashloan_test.go new file mode 100644 index 0000000..0b83222 --- /dev/null +++ b/pkg/execution/flashloan_test.go @@ -0,0 +1,482 @@ +package execution + +import ( + "context" + "log/slog" + "math/big" + "os" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/your-org/mev-bot/pkg/arbitrage" +) + +func TestNewFlashloanManager(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewFlashloanManager(nil, logger) + + assert.NotNil(t, manager) + assert.NotNil(t, manager.config) + assert.NotNil(t, manager.aaveV3Encoder) + assert.NotNil(t, manager.uniswapV3Encoder) + assert.NotNil(t, manager.uniswapV2Encoder) +} + +func TestDefaultFlashloanConfig(t *testing.T) { + config := DefaultFlashloanConfig() + + assert.NotNil(t, config) + assert.Len(t, config.PreferredProviders, 3) + assert.Equal(t, FlashloanProviderAaveV3, config.PreferredProviders[0]) + assert.Equal(t, uint16(9), config.AaveV3FeeBPS) + assert.Equal(t, uint16(0), config.UniswapV3FeeBPS) + assert.Equal(t, uint16(30), config.UniswapV2FeeBPS) +} + +func TestFlashloanManager_BuildFlashloanTransaction_AaveV3(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultFlashloanConfig() + config.ExecutorContract = common.HexToAddress("0x0000000000000000000000000000000000000001") + + manager := NewFlashloanManager(config, logger) + + opp := &arbitrage.Opportunity{ + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + }, + }, + } + + swapCalldata := []byte{0x01, 0x02, 0x03, 0x04} + + tx, err := manager.BuildFlashloanTransaction(context.Background(), opp, swapCalldata) + + require.NoError(t, err) + assert.NotNil(t, tx) + assert.Equal(t, FlashloanProviderAaveV3, tx.Provider) + assert.NotEmpty(t, tx.To) + assert.NotEmpty(t, tx.Data) + assert.NotNil(t, tx.Fee) + assert.True(t, tx.Fee.Cmp(big.NewInt(0)) > 0) // Fee should be > 0 +} + +func TestFlashloanManager_BuildFlashloanTransaction_UniswapV3(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultFlashloanConfig() + config.ExecutorContract = common.HexToAddress("0x0000000000000000000000000000000000000001") + config.PreferredProviders = []FlashloanProvider{FlashloanProviderUniswapV3} + + manager := NewFlashloanManager(config, logger) + + opp := &arbitrage.Opportunity{ + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + }, + }, + } + + swapCalldata := []byte{0x01, 0x02, 0x03, 0x04} + + tx, err := manager.BuildFlashloanTransaction(context.Background(), opp, swapCalldata) + + require.NoError(t, err) + assert.NotNil(t, tx) + assert.Equal(t, FlashloanProviderUniswapV3, tx.Provider) + assert.NotEmpty(t, tx.To) + assert.NotEmpty(t, tx.Data) + assert.Equal(t, big.NewInt(0), tx.Fee) // UniswapV3 has no separate fee +} + +func TestFlashloanManager_BuildFlashloanTransaction_UniswapV2(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultFlashloanConfig() + config.ExecutorContract = common.HexToAddress("0x0000000000000000000000000000000000000001") + config.PreferredProviders = []FlashloanProvider{FlashloanProviderUniswapV2} + + manager := NewFlashloanManager(config, logger) + + opp := &arbitrage.Opportunity{ + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + }, + }, + } + + swapCalldata := []byte{0x01, 0x02, 0x03, 0x04} + + tx, err := manager.BuildFlashloanTransaction(context.Background(), opp, swapCalldata) + + require.NoError(t, err) + assert.NotNil(t, tx) + assert.Equal(t, FlashloanProviderUniswapV2, tx.Provider) + assert.NotEmpty(t, tx.To) + assert.NotEmpty(t, tx.Data) + assert.True(t, tx.Fee.Cmp(big.NewInt(0)) > 0) // Fee should be > 0 +} + +func TestFlashloanManager_selectProvider_NoProviders(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := &FlashloanConfig{ + PreferredProviders: []FlashloanProvider{}, + } + + manager := NewFlashloanManager(config, logger) + + token := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + amount := big.NewInt(1e18) + + _, err := manager.selectProvider(context.Background(), token, amount) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no flashloan providers configured") +} + +func TestFlashloanManager_calculateFee(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewFlashloanManager(nil, logger) + + tests := []struct { + name string + amount *big.Int + feeBPS uint16 + expectedFee *big.Int + }{ + { + name: "Aave V3 fee (9 bps)", + amount: big.NewInt(1e18), + feeBPS: 9, + expectedFee: big.NewInt(9e14), // 0.0009 * 1e18 + }, + { + name: "Uniswap V2 fee (30 bps)", + amount: big.NewInt(1e18), + feeBPS: 30, + expectedFee: big.NewInt(3e15), // 0.003 * 1e18 + }, + { + name: "Zero fee", + amount: big.NewInt(1e18), + feeBPS: 0, + expectedFee: big.NewInt(0), + }, + { + name: "Small amount", + amount: big.NewInt(1000), + feeBPS: 9, + expectedFee: big.NewInt(0), // Rounds down to 0 + }, + { + name: "Large amount", + amount: big.NewInt(1000e18), + feeBPS: 9, + expectedFee: big.NewInt(9e20), // 0.0009 * 1000e18 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fee := manager.calculateFee(tt.amount, tt.feeBPS) + assert.Equal(t, tt.expectedFee, fee) + }) + } +} + +func TestFlashloanManager_CalculateTotalCost(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewFlashloanManager(nil, logger) + + tests := []struct { + name string + amount *big.Int + feeBPS uint16 + expectedTotal *big.Int + }{ + { + name: "Aave V3 cost", + amount: big.NewInt(1e18), + feeBPS: 9, + expectedTotal: big.NewInt(1.0009e18), + }, + { + name: "Uniswap V2 cost", + amount: big.NewInt(1e18), + feeBPS: 30, + expectedTotal: big.NewInt(1.003e18), + }, + { + name: "Zero fee cost", + amount: big.NewInt(1e18), + feeBPS: 0, + expectedTotal: big.NewInt(1e18), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + total := manager.CalculateTotalCost(tt.amount, tt.feeBPS) + assert.Equal(t, tt.expectedTotal, total) + }) + } +} + +func TestAaveV3FlashloanEncoder_EncodeFlashloan(t *testing.T) { + encoder := NewAaveV3FlashloanEncoder() + + assets := []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + } + amounts := []*big.Int{ + big.NewInt(1e18), + } + receiverAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + params := []byte{0x01, 0x02, 0x03, 0x04} + + to, data, err := encoder.EncodeFlashloan(assets, amounts, receiverAddress, params) + + require.NoError(t, err) + assert.Equal(t, AaveV3PoolAddress, to) + assert.NotEmpty(t, data) + + // Check method ID + // flashLoan(address,address[],uint256[],uint256[],address,bytes,uint16) + assert.GreaterOrEqual(t, len(data), 4) +} + +func TestAaveV3FlashloanEncoder_EncodeFlashloan_MultipleAssets(t *testing.T) { + encoder := NewAaveV3FlashloanEncoder() + + assets := []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + } + amounts := []*big.Int{ + big.NewInt(1e18), + big.NewInt(1500e6), + } + receiverAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + params := []byte{0x01, 0x02, 0x03, 0x04} + + to, data, err := encoder.EncodeFlashloan(assets, amounts, receiverAddress, params) + + require.NoError(t, err) + assert.Equal(t, AaveV3PoolAddress, to) + assert.NotEmpty(t, data) +} + +func TestAaveV3FlashloanEncoder_EncodeFlashloan_EmptyParams(t *testing.T) { + encoder := NewAaveV3FlashloanEncoder() + + assets := []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + } + amounts := []*big.Int{ + big.NewInt(1e18), + } + receiverAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + params := []byte{} + + to, data, err := encoder.EncodeFlashloan(assets, amounts, receiverAddress, params) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestUniswapV3FlashloanEncoder_EncodeFlash(t *testing.T) { + encoder := NewUniswapV3FlashloanEncoder() + + token := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + amount := big.NewInt(1e18) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + data := []byte{0x01, 0x02, 0x03, 0x04} + + to, calldata, err := encoder.EncodeFlash(token, amount, poolAddress, recipient, data) + + require.NoError(t, err) + assert.Equal(t, poolAddress, to) + assert.NotEmpty(t, calldata) + + // Check method ID + // flash(address,uint256,uint256,bytes) + assert.GreaterOrEqual(t, len(calldata), 4) +} + +func TestUniswapV3FlashloanEncoder_EncodeFlash_EmptyData(t *testing.T) { + encoder := NewUniswapV3FlashloanEncoder() + + token := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + amount := big.NewInt(1e18) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + data := []byte{} + + to, calldata, err := encoder.EncodeFlash(token, amount, poolAddress, recipient, data) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, calldata) +} + +func TestUniswapV2FlashloanEncoder_EncodeFlash(t *testing.T) { + encoder := NewUniswapV2FlashloanEncoder() + + token := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + amount := big.NewInt(1e18) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + data := []byte{0x01, 0x02, 0x03, 0x04} + + to, calldata, err := encoder.EncodeFlash(token, amount, poolAddress, recipient, data) + + require.NoError(t, err) + assert.Equal(t, poolAddress, to) + assert.NotEmpty(t, calldata) + + // Check method ID + // swap(uint256,uint256,address,bytes) + assert.GreaterOrEqual(t, len(calldata), 4) +} + +func TestUniswapV2FlashloanEncoder_EncodeFlash_EmptyData(t *testing.T) { + encoder := NewUniswapV2FlashloanEncoder() + + token := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + amount := big.NewInt(1e18) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + data := []byte{} + + to, calldata, err := encoder.EncodeFlash(token, amount, poolAddress, recipient, data) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, calldata) +} + +func TestFlashloanManager_ZeroAmount(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultFlashloanConfig() + config.ExecutorContract = common.HexToAddress("0x0000000000000000000000000000000000000001") + + manager := NewFlashloanManager(config, logger) + + opp := &arbitrage.Opportunity{ + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(0), + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + }, + }, + } + + swapCalldata := []byte{0x01, 0x02, 0x03, 0x04} + + tx, err := manager.BuildFlashloanTransaction(context.Background(), opp, swapCalldata) + + require.NoError(t, err) + assert.NotNil(t, tx) + assert.Equal(t, big.NewInt(0), tx.Fee) // Fee should be 0 for 0 amount +} + +func TestFlashloanManager_LargeAmount(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultFlashloanConfig() + config.ExecutorContract = common.HexToAddress("0x0000000000000000000000000000000000000001") + + manager := NewFlashloanManager(config, logger) + + // 1000 ETH + largeAmount := new(big.Int).Mul(big.NewInt(1000), big.NewInt(1e18)) + + opp := &arbitrage.Opportunity{ + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: largeAmount, + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + }, + }, + } + + swapCalldata := []byte{0x01, 0x02, 0x03, 0x04} + + tx, err := manager.BuildFlashloanTransaction(context.Background(), opp, swapCalldata) + + require.NoError(t, err) + assert.NotNil(t, tx) + assert.True(t, tx.Fee.Cmp(big.NewInt(0)) > 0) + + // Verify fee is reasonable (0.09% of 1000 ETH = 0.9 ETH) + expectedFee := new(big.Int).Mul(big.NewInt(9e17), big.NewInt(1)) // 0.9 ETH + assert.Equal(t, expectedFee, tx.Fee) +} + +// Benchmark tests +func BenchmarkFlashloanManager_BuildFlashloanTransaction(b *testing.B) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultFlashloanConfig() + config.ExecutorContract = common.HexToAddress("0x0000000000000000000000000000000000000001") + + manager := NewFlashloanManager(config, logger) + + opp := &arbitrage.Opportunity{ + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + }, + }, + } + + swapCalldata := []byte{0x01, 0x02, 0x03, 0x04} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = manager.BuildFlashloanTransaction(context.Background(), opp, swapCalldata) + } +} + +func BenchmarkAaveV3FlashloanEncoder_EncodeFlashloan(b *testing.B) { + encoder := NewAaveV3FlashloanEncoder() + + assets := []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + } + amounts := []*big.Int{ + big.NewInt(1e18), + } + receiverAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + params := []byte{0x01, 0x02, 0x03, 0x04} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = encoder.EncodeFlashloan(assets, amounts, receiverAddress, params) + } +} diff --git a/pkg/execution/risk_manager_test.go b/pkg/execution/risk_manager_test.go new file mode 100644 index 0000000..08ed0f0 --- /dev/null +++ b/pkg/execution/risk_manager_test.go @@ -0,0 +1,633 @@ +package execution + +import ( + "context" + "log/slog" + "math/big" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/your-org/mev-bot/pkg/arbitrage" +) + +func TestDefaultRiskManagerConfig(t *testing.T) { + config := DefaultRiskManagerConfig() + + assert.NotNil(t, config) + assert.True(t, config.Enabled) + assert.NotNil(t, config.MaxPositionSize) + assert.NotNil(t, config.MaxDailyVolume) + assert.NotNil(t, config.MinProfitThreshold) + assert.Equal(t, float64(0.01), config.MinROI) + assert.Equal(t, uint16(200), config.MaxSlippageBPS) + assert.Equal(t, uint64(5), config.MaxConcurrentTxs) +} + +func TestNewRiskManager(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewRiskManager(nil, nil, logger) + + assert.NotNil(t, manager) + assert.NotNil(t, manager.config) + assert.NotNil(t, manager.activeTxs) + assert.NotNil(t, manager.dailyVolume) + assert.NotNil(t, manager.recentFailures) + assert.False(t, manager.circuitBreakerOpen) +} + +func TestRiskManager_AssessRisk_Success(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false // Disable simulation for unit test + + manager := NewRiskManager(config, nil, logger) + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + OutputAmount: big.NewInt(1.1e18), + NetProfit: big.NewInt(0.1e18), + ROI: 0.1, // 10% + EstimatedGas: 150000, + } + + tx := &SwapTransaction{ + MaxFeePerGas: big.NewInt(50e9), // 50 gwei + MaxPriorityFeePerGas: big.NewInt(2e9), // 2 gwei + GasLimit: 180000, + Slippage: 50, // 0.5% + } + + assessment, err := manager.AssessRisk(context.Background(), opp, tx) + + require.NoError(t, err) + assert.NotNil(t, assessment) + assert.True(t, assessment.Approved) + assert.Empty(t, assessment.Warnings) +} + +func TestRiskManager_AssessRisk_CircuitBreakerOpen(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false + + manager := NewRiskManager(config, nil, logger) + + // Open circuit breaker + manager.openCircuitBreaker() + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + OutputAmount: big.NewInt(1.1e18), + NetProfit: big.NewInt(0.1e18), + ROI: 0.1, + EstimatedGas: 150000, + } + + tx := &SwapTransaction{ + MaxFeePerGas: big.NewInt(50e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + GasLimit: 180000, + Slippage: 50, + } + + assessment, err := manager.AssessRisk(context.Background(), opp, tx) + + require.NoError(t, err) + assert.False(t, assessment.Approved) + assert.Contains(t, assessment.Reason, "circuit breaker") +} + +func TestRiskManager_AssessRisk_PositionSizeExceeded(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false + + manager := NewRiskManager(config, nil, logger) + + // Create opportunity with amount exceeding max position size + largeAmount := new(big.Int).Add(config.MaxPositionSize, big.NewInt(1)) + + opp := &arbitrage.Opportunity{ + InputAmount: largeAmount, + OutputAmount: new(big.Int).Mul(largeAmount, big.NewInt(11)), + NetProfit: big.NewInt(1e18), + ROI: 0.1, + EstimatedGas: 150000, + } + + tx := &SwapTransaction{ + MaxFeePerGas: big.NewInt(50e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + GasLimit: 180000, + Slippage: 50, + } + + assessment, err := manager.AssessRisk(context.Background(), opp, tx) + + require.NoError(t, err) + assert.False(t, assessment.Approved) + assert.Contains(t, assessment.Reason, "position size") +} + +func TestRiskManager_AssessRisk_DailyVolumeExceeded(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false + + manager := NewRiskManager(config, nil, logger) + + // Set daily volume to max + manager.dailyVolume = config.MaxDailyVolume + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + OutputAmount: big.NewInt(1.1e18), + NetProfit: big.NewInt(0.1e18), + ROI: 0.1, + EstimatedGas: 150000, + } + + tx := &SwapTransaction{ + MaxFeePerGas: big.NewInt(50e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + GasLimit: 180000, + Slippage: 50, + } + + assessment, err := manager.AssessRisk(context.Background(), opp, tx) + + require.NoError(t, err) + assert.False(t, assessment.Approved) + assert.Contains(t, assessment.Reason, "daily volume") +} + +func TestRiskManager_AssessRisk_GasPriceTooHigh(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false + + manager := NewRiskManager(config, nil, logger) + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + OutputAmount: big.NewInt(1.1e18), + NetProfit: big.NewInt(0.1e18), + ROI: 0.1, + EstimatedGas: 150000, + } + + // Set gas price above max + tx := &SwapTransaction{ + MaxFeePerGas: new(big.Int).Add(config.MaxGasPrice, big.NewInt(1)), + MaxPriorityFeePerGas: big.NewInt(2e9), + GasLimit: 180000, + Slippage: 50, + } + + assessment, err := manager.AssessRisk(context.Background(), opp, tx) + + require.NoError(t, err) + assert.False(t, assessment.Approved) + assert.Contains(t, assessment.Reason, "gas price") +} + +func TestRiskManager_AssessRisk_ProfitTooLow(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false + + manager := NewRiskManager(config, nil, logger) + + // Profit below threshold + lowProfit := new(big.Int).Sub(config.MinProfitThreshold, big.NewInt(1)) + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + OutputAmount: new(big.Int).Add(big.NewInt(1e18), lowProfit), + NetProfit: lowProfit, + ROI: 0.00001, + EstimatedGas: 150000, + } + + tx := &SwapTransaction{ + MaxFeePerGas: big.NewInt(50e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + GasLimit: 180000, + Slippage: 50, + } + + assessment, err := manager.AssessRisk(context.Background(), opp, tx) + + require.NoError(t, err) + assert.False(t, assessment.Approved) + assert.Contains(t, assessment.Reason, "profit") +} + +func TestRiskManager_AssessRisk_ROITooLow(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false + + manager := NewRiskManager(config, nil, logger) + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + OutputAmount: big.NewInt(1.005e18), + NetProfit: big.NewInt(0.005e18), // 0.5% ROI, below 1% threshold + ROI: 0.005, + EstimatedGas: 150000, + } + + tx := &SwapTransaction{ + MaxFeePerGas: big.NewInt(50e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + GasLimit: 180000, + Slippage: 50, + } + + assessment, err := manager.AssessRisk(context.Background(), opp, tx) + + require.NoError(t, err) + assert.False(t, assessment.Approved) + assert.Contains(t, assessment.Reason, "ROI") +} + +func TestRiskManager_AssessRisk_SlippageTooHigh(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false + + manager := NewRiskManager(config, nil, logger) + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + OutputAmount: big.NewInt(1.1e18), + NetProfit: big.NewInt(0.1e18), + ROI: 0.1, + EstimatedGas: 150000, + } + + tx := &SwapTransaction{ + MaxFeePerGas: big.NewInt(50e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + GasLimit: 180000, + Slippage: 300, // 3%, above 2% max + } + + assessment, err := manager.AssessRisk(context.Background(), opp, tx) + + require.NoError(t, err) + assert.False(t, assessment.Approved) + assert.Contains(t, assessment.Reason, "slippage") +} + +func TestRiskManager_AssessRisk_ConcurrentLimitExceeded(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false + config.MaxConcurrentTxs = 2 + + manager := NewRiskManager(config, nil, logger) + + // Add max concurrent transactions + manager.activeTxs[common.HexToHash("0x01")] = &ActiveTransaction{} + manager.activeTxs[common.HexToHash("0x02")] = &ActiveTransaction{} + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + OutputAmount: big.NewInt(1.1e18), + NetProfit: big.NewInt(0.1e18), + ROI: 0.1, + EstimatedGas: 150000, + } + + tx := &SwapTransaction{ + MaxFeePerGas: big.NewInt(50e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + GasLimit: 180000, + Slippage: 50, + } + + assessment, err := manager.AssessRisk(context.Background(), opp, tx) + + require.NoError(t, err) + assert.False(t, assessment.Approved) + assert.Contains(t, assessment.Reason, "concurrent") +} + +func TestRiskManager_TrackTransaction(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewRiskManager(nil, nil, logger) + + hash := common.HexToHash("0x123") + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + NetProfit: big.NewInt(0.1e18), + } + gasPrice := big.NewInt(50e9) + + manager.TrackTransaction(hash, opp, gasPrice) + + // Check transaction is tracked + manager.mu.RLock() + tx, exists := manager.activeTxs[hash] + manager.mu.RUnlock() + + assert.True(t, exists) + assert.NotNil(t, tx) + assert.Equal(t, hash, tx.Hash) + assert.Equal(t, opp.InputAmount, tx.Amount) + assert.Equal(t, gasPrice, tx.GasPrice) +} + +func TestRiskManager_UntrackTransaction(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewRiskManager(nil, nil, logger) + + hash := common.HexToHash("0x123") + manager.activeTxs[hash] = &ActiveTransaction{Hash: hash} + + manager.UntrackTransaction(hash) + + // Check transaction is no longer tracked + manager.mu.RLock() + _, exists := manager.activeTxs[hash] + manager.mu.RUnlock() + + assert.False(t, exists) +} + +func TestRiskManager_RecordFailure(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.CircuitBreakerFailures = 3 + config.CircuitBreakerWindow = 1 * time.Minute + + manager := NewRiskManager(config, nil, logger) + + hash := common.HexToHash("0x123") + + // Record failures below threshold + manager.RecordFailure(hash, "test failure 1") + assert.False(t, manager.circuitBreakerOpen) + + manager.RecordFailure(hash, "test failure 2") + assert.False(t, manager.circuitBreakerOpen) + + // Third failure should open circuit breaker + manager.RecordFailure(hash, "test failure 3") + assert.True(t, manager.circuitBreakerOpen) +} + +func TestRiskManager_RecordSuccess(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewRiskManager(nil, nil, logger) + + hash := common.HexToHash("0x123") + actualProfit := big.NewInt(0.1e18) + + manager.RecordSuccess(hash, actualProfit) + + // Check that recent failures were cleared + assert.Empty(t, manager.recentFailures) +} + +func TestRiskManager_CircuitBreaker_Cooldown(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.CircuitBreakerCooldown = 1 * time.Millisecond + + manager := NewRiskManager(config, nil, logger) + + // Open circuit breaker + manager.openCircuitBreaker() + assert.True(t, manager.circuitBreakerOpen) + + // Wait for cooldown + time.Sleep(2 * time.Millisecond) + + // Circuit breaker should be closed after cooldown + assert.False(t, manager.checkCircuitBreaker()) +} + +func TestRiskManager_checkConcurrentLimit(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.MaxConcurrentTxs = 3 + + manager := NewRiskManager(config, nil, logger) + + // Add transactions below limit + manager.activeTxs[common.HexToHash("0x01")] = &ActiveTransaction{} + manager.activeTxs[common.HexToHash("0x02")] = &ActiveTransaction{} + + assert.True(t, manager.checkConcurrentLimit()) + + // Add transaction at limit + manager.activeTxs[common.HexToHash("0x03")] = &ActiveTransaction{} + + assert.False(t, manager.checkConcurrentLimit()) +} + +func TestRiskManager_checkPositionSize(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.MaxPositionSize = big.NewInt(10e18) + + manager := NewRiskManager(config, nil, logger) + + assert.True(t, manager.checkPositionSize(big.NewInt(5e18))) + assert.True(t, manager.checkPositionSize(big.NewInt(10e18))) + assert.False(t, manager.checkPositionSize(big.NewInt(11e18))) +} + +func TestRiskManager_checkDailyVolume(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.MaxDailyVolume = big.NewInt(100e18) + + manager := NewRiskManager(config, nil, logger) + manager.dailyVolume = big.NewInt(90e18) + + assert.True(t, manager.checkDailyVolume(big.NewInt(5e18))) + assert.True(t, manager.checkDailyVolume(big.NewInt(10e18))) + assert.False(t, manager.checkDailyVolume(big.NewInt(15e18))) +} + +func TestRiskManager_updateDailyVolume(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewRiskManager(nil, nil, logger) + + initialVolume := big.NewInt(10e18) + manager.dailyVolume = initialVolume + + addAmount := big.NewInt(5e18) + manager.updateDailyVolume(addAmount) + + expectedVolume := new(big.Int).Add(initialVolume, addAmount) + assert.Equal(t, expectedVolume, manager.dailyVolume) +} + +func TestRiskManager_checkGasPrice(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.MaxGasPrice = big.NewInt(100e9) // 100 gwei + + manager := NewRiskManager(config, nil, logger) + + assert.True(t, manager.checkGasPrice(big.NewInt(50e9))) + assert.True(t, manager.checkGasPrice(big.NewInt(100e9))) + assert.False(t, manager.checkGasPrice(big.NewInt(101e9))) +} + +func TestRiskManager_checkGasCost(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.MaxGasCost = big.NewInt(0.1e18) // 0.1 ETH + + manager := NewRiskManager(config, nil, logger) + + assert.True(t, manager.checkGasCost(big.NewInt(0.05e18))) + assert.True(t, manager.checkGasCost(big.NewInt(0.1e18))) + assert.False(t, manager.checkGasCost(big.NewInt(0.11e18))) +} + +func TestRiskManager_checkMinProfit(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.MinProfitThreshold = big.NewInt(0.01e18) // 0.01 ETH + + manager := NewRiskManager(config, nil, logger) + + assert.False(t, manager.checkMinProfit(big.NewInt(0.005e18))) + assert.True(t, manager.checkMinProfit(big.NewInt(0.01e18))) + assert.True(t, manager.checkMinProfit(big.NewInt(0.02e18))) +} + +func TestRiskManager_checkMinROI(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.MinROI = 0.01 // 1% + + manager := NewRiskManager(config, nil, logger) + + assert.False(t, manager.checkMinROI(0.005)) // 0.5% + assert.True(t, manager.checkMinROI(0.01)) // 1% + assert.True(t, manager.checkMinROI(0.02)) // 2% +} + +func TestRiskManager_checkSlippage(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.MaxSlippageBPS = 200 // 2% + + manager := NewRiskManager(config, nil, logger) + + assert.True(t, manager.checkSlippage(100)) // 1% + assert.True(t, manager.checkSlippage(200)) // 2% + assert.False(t, manager.checkSlippage(300)) // 3% +} + +func TestRiskManager_GetActiveTransactions(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewRiskManager(nil, nil, logger) + + // Add some active transactions + manager.activeTxs[common.HexToHash("0x01")] = &ActiveTransaction{Hash: common.HexToHash("0x01")} + manager.activeTxs[common.HexToHash("0x02")] = &ActiveTransaction{Hash: common.HexToHash("0x02")} + + activeTxs := manager.GetActiveTransactions() + + assert.Len(t, activeTxs, 2) +} + +func TestRiskManager_GetStats(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewRiskManager(nil, nil, logger) + + // Add some state + manager.activeTxs[common.HexToHash("0x01")] = &ActiveTransaction{} + manager.dailyVolume = big.NewInt(50e18) + manager.circuitBreakerOpen = true + + stats := manager.GetStats() + + assert.NotNil(t, stats) + assert.Equal(t, 1, stats["active_transactions"]) + assert.Equal(t, "50000000000000000000", stats["daily_volume"]) + assert.Equal(t, true, stats["circuit_breaker_open"]) +} + +func TestRiskManager_AssessRisk_WithWarnings(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false + + manager := NewRiskManager(config, nil, logger) + + // Create opportunity with high gas cost (should generate warning) + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + OutputAmount: big.NewInt(1.1e18), + NetProfit: big.NewInt(0.1e18), + ROI: 0.1, + EstimatedGas: 2000000, // Very high gas + } + + tx := &SwapTransaction{ + MaxFeePerGas: big.NewInt(50e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + GasLimit: 2400000, + Slippage: 50, + } + + assessment, err := manager.AssessRisk(context.Background(), opp, tx) + + require.NoError(t, err) + assert.True(t, assessment.Approved) // Should still be approved + assert.NotEmpty(t, assessment.Warnings) // But with warnings +} + +// Benchmark tests +func BenchmarkRiskManager_AssessRisk(b *testing.B) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + config := DefaultRiskManagerConfig() + config.SimulationEnabled = false + + manager := NewRiskManager(config, nil, logger) + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + OutputAmount: big.NewInt(1.1e18), + NetProfit: big.NewInt(0.1e18), + ROI: 0.1, + EstimatedGas: 150000, + } + + tx := &SwapTransaction{ + MaxFeePerGas: big.NewInt(50e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + GasLimit: 180000, + Slippage: 50, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = manager.AssessRisk(context.Background(), opp, tx) + } +} + +func BenchmarkRiskManager_checkCircuitBreaker(b *testing.B) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + manager := NewRiskManager(nil, nil, logger) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = manager.checkCircuitBreaker() + } +} diff --git a/pkg/execution/transaction_builder_test.go b/pkg/execution/transaction_builder_test.go new file mode 100644 index 0000000..b019665 --- /dev/null +++ b/pkg/execution/transaction_builder_test.go @@ -0,0 +1,560 @@ +package execution + +import ( + "context" + "log/slog" + "math/big" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/your-org/mev-bot/pkg/arbitrage" + mevtypes "github.com/your-org/mev-bot/pkg/types" +) + +func TestDefaultTransactionBuilderConfig(t *testing.T) { + config := DefaultTransactionBuilderConfig() + + assert.NotNil(t, config) + assert.Equal(t, uint16(50), config.DefaultSlippageBPS) + assert.Equal(t, uint16(300), config.MaxSlippageBPS) + assert.Equal(t, float64(1.2), config.GasLimitMultiplier) + assert.Equal(t, uint64(3000000), config.MaxGasLimit) + assert.Equal(t, 5*time.Minute, config.DefaultDeadline) +} + +func TestNewTransactionBuilder(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) // Arbitrum + + builder := NewTransactionBuilder(nil, chainID, logger) + + assert.NotNil(t, builder) + assert.NotNil(t, builder.config) + assert.Equal(t, chainID, builder.chainID) + assert.NotNil(t, builder.uniswapV2Encoder) + assert.NotNil(t, builder.uniswapV3Encoder) + assert.NotNil(t, builder.curveEncoder) +} + +func TestTransactionBuilder_BuildTransaction_SingleSwap_UniswapV2(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + opp := &arbitrage.Opportunity{ + ID: "test-opp-1", + Type: arbitrage.OpportunityTypeTwoPool, + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + OutputToken: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + OutputAmount: big.NewInt(1500e6), + Path: []arbitrage.SwapStep{ + { + Protocol: mevtypes.ProtocolUniswapV2, + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + AmountIn: big.NewInt(1e18), + AmountOut: big.NewInt(1500e6), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + }, + EstimatedGas: 150000, + } + + fromAddress := common.HexToAddress("0x0000000000000000000000000000000000000002") + + tx, err := builder.BuildTransaction(context.Background(), opp, fromAddress) + + require.NoError(t, err) + assert.NotNil(t, tx) + assert.NotEmpty(t, tx.To) + assert.NotEmpty(t, tx.Data) + assert.NotNil(t, tx.Value) + assert.Greater(t, tx.GasLimit, uint64(0)) + assert.NotNil(t, tx.MaxFeePerGas) + assert.NotNil(t, tx.MaxPriorityFeePerGas) + assert.NotNil(t, tx.MinOutput) + assert.False(t, tx.RequiresFlashloan) + assert.Equal(t, uint16(50), tx.Slippage) // Default slippage +} + +func TestTransactionBuilder_BuildTransaction_SingleSwap_UniswapV3(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + opp := &arbitrage.Opportunity{ + ID: "test-opp-2", + Type: arbitrage.OpportunityTypeTwoPool, + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + OutputToken: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + OutputAmount: big.NewInt(1500e6), + Path: []arbitrage.SwapStep{ + { + Protocol: mevtypes.ProtocolUniswapV3, + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + AmountIn: big.NewInt(1e18), + AmountOut: big.NewInt(1500e6), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + Fee: 3000, // 0.3% + }, + }, + EstimatedGas: 150000, + } + + fromAddress := common.HexToAddress("0x0000000000000000000000000000000000000002") + + tx, err := builder.BuildTransaction(context.Background(), opp, fromAddress) + + require.NoError(t, err) + assert.NotNil(t, tx) + assert.NotEmpty(t, tx.To) + assert.NotEmpty(t, tx.Data) + assert.Equal(t, UniswapV3SwapRouterAddress, tx.To) +} + +func TestTransactionBuilder_BuildTransaction_MultiHop_UniswapV2(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + opp := &arbitrage.Opportunity{ + ID: "test-opp-3", + Type: arbitrage.OpportunityTypeMultiHop, + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + OutputToken: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + OutputAmount: big.NewInt(1e7), + Path: []arbitrage.SwapStep{ + { + Protocol: mevtypes.ProtocolUniswapV2, + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + AmountIn: big.NewInt(1e18), + AmountOut: big.NewInt(1500e6), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + { + Protocol: mevtypes.ProtocolUniswapV2, + TokenIn: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + TokenOut: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + AmountIn: big.NewInt(1500e6), + AmountOut: big.NewInt(1e7), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + }, + }, + EstimatedGas: 250000, + } + + fromAddress := common.HexToAddress("0x0000000000000000000000000000000000000002") + + tx, err := builder.BuildTransaction(context.Background(), opp, fromAddress) + + require.NoError(t, err) + assert.NotNil(t, tx) + assert.NotEmpty(t, tx.To) + assert.NotEmpty(t, tx.Data) + assert.Equal(t, UniswapV2RouterAddress, tx.To) +} + +func TestTransactionBuilder_BuildTransaction_MultiHop_UniswapV3(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + opp := &arbitrage.Opportunity{ + ID: "test-opp-4", + Type: arbitrage.OpportunityTypeMultiHop, + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + OutputToken: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + OutputAmount: big.NewInt(1e7), + Path: []arbitrage.SwapStep{ + { + Protocol: mevtypes.ProtocolUniswapV3, + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + AmountIn: big.NewInt(1e18), + AmountOut: big.NewInt(1500e6), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + Fee: 3000, + }, + { + Protocol: mevtypes.ProtocolUniswapV3, + TokenIn: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + TokenOut: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + AmountIn: big.NewInt(1500e6), + AmountOut: big.NewInt(1e7), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Fee: 500, + }, + }, + EstimatedGas: 250000, + } + + fromAddress := common.HexToAddress("0x0000000000000000000000000000000000000002") + + tx, err := builder.BuildTransaction(context.Background(), opp, fromAddress) + + require.NoError(t, err) + assert.NotNil(t, tx) + assert.Equal(t, UniswapV3SwapRouterAddress, tx.To) +} + +func TestTransactionBuilder_BuildTransaction_Curve(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + opp := &arbitrage.Opportunity{ + ID: "test-opp-5", + Type: arbitrage.OpportunityTypeTwoPool, + InputToken: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + InputAmount: big.NewInt(1500e6), + OutputToken: common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"), + OutputAmount: big.NewInt(1500e6), + Path: []arbitrage.SwapStep{ + { + Protocol: mevtypes.ProtocolCurve, + TokenIn: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + TokenOut: common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"), + AmountIn: big.NewInt(1500e6), + AmountOut: big.NewInt(1500e6), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + }, + EstimatedGas: 200000, + } + + fromAddress := common.HexToAddress("0x0000000000000000000000000000000000000002") + + tx, err := builder.BuildTransaction(context.Background(), opp, fromAddress) + + require.NoError(t, err) + assert.NotNil(t, tx) + // For Curve, tx.To should be the pool address + assert.Equal(t, opp.Path[0].PoolAddress, tx.To) +} + +func TestTransactionBuilder_BuildTransaction_EmptyPath(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + opp := &arbitrage.Opportunity{ + ID: "test-opp-6", + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + Path: []arbitrage.SwapStep{}, + } + + fromAddress := common.HexToAddress("0x0000000000000000000000000000000000000002") + + _, err := builder.BuildTransaction(context.Background(), opp, fromAddress) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty swap path") +} + +func TestTransactionBuilder_BuildTransaction_UnsupportedProtocol(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + opp := &arbitrage.Opportunity{ + ID: "test-opp-7", + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + Path: []arbitrage.SwapStep{ + { + Protocol: "unknown_protocol", + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + AmountIn: big.NewInt(1e18), + AmountOut: big.NewInt(1500e6), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + }, + } + + fromAddress := common.HexToAddress("0x0000000000000000000000000000000000000002") + + _, err := builder.BuildTransaction(context.Background(), opp, fromAddress) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported protocol") +} + +func TestTransactionBuilder_calculateMinOutput(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + tests := []struct { + name string + outputAmount *big.Int + slippageBPS uint16 + expectedMin *big.Int + }{ + { + name: "0.5% slippage", + outputAmount: big.NewInt(1000e6), + slippageBPS: 50, + expectedMin: big.NewInt(995e6), // 0.5% less + }, + { + name: "1% slippage", + outputAmount: big.NewInt(1000e6), + slippageBPS: 100, + expectedMin: big.NewInt(990e6), // 1% less + }, + { + name: "3% slippage", + outputAmount: big.NewInt(1000e6), + slippageBPS: 300, + expectedMin: big.NewInt(970e6), // 3% less + }, + { + name: "Zero slippage", + outputAmount: big.NewInt(1000e6), + slippageBPS: 0, + expectedMin: big.NewInt(1000e6), // No change + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + minOutput := builder.calculateMinOutput(tt.outputAmount, tt.slippageBPS) + assert.Equal(t, tt.expectedMin, minOutput) + }) + } +} + +func TestTransactionBuilder_calculateGasLimit(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + tests := []struct { + name string + estimatedGas uint64 + expectedMin uint64 + expectedMax uint64 + }{ + { + name: "Normal gas estimate", + estimatedGas: 150000, + expectedMin: 180000, // 150k * 1.2 + expectedMax: 180001, + }, + { + name: "High gas estimate", + estimatedGas: 2500000, + expectedMin: 3000000, // Capped at max + expectedMax: 3000000, + }, + { + name: "Zero gas estimate", + estimatedGas: 0, + expectedMin: 0, // 0 * 1.2 + expectedMax: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gasLimit := builder.calculateGasLimit(tt.estimatedGas) + assert.GreaterOrEqual(t, gasLimit, tt.expectedMin) + assert.LessOrEqual(t, gasLimit, tt.expectedMax) + }) + } +} + +func TestTransactionBuilder_SignTransaction(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + // Create a test private key + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + tx := &SwapTransaction{ + To: common.HexToAddress("0x0000000000000000000000000000000000000001"), + Data: []byte{0x01, 0x02, 0x03, 0x04}, + Value: big.NewInt(0), + GasLimit: 180000, + MaxFeePerGas: big.NewInt(100e9), // 100 gwei + MaxPriorityFeePerGas: big.NewInt(2e9), // 2 gwei + } + + nonce := uint64(5) + + signedTx, err := builder.SignTransaction(tx, nonce, crypto.FromECDSA(privateKey)) + + require.NoError(t, err) + assert.NotNil(t, signedTx) + assert.Equal(t, nonce, signedTx.Nonce()) + assert.Equal(t, tx.To, *signedTx.To()) + assert.Equal(t, tx.GasLimit, signedTx.Gas()) + assert.Equal(t, tx.MaxFeePerGas, signedTx.GasFeeCap()) + assert.Equal(t, tx.MaxPriorityFeePerGas, signedTx.GasTipCap()) +} + +func TestTransactionBuilder_SignTransaction_InvalidKey(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + tx := &SwapTransaction{ + To: common.HexToAddress("0x0000000000000000000000000000000000000001"), + Data: []byte{0x01, 0x02, 0x03, 0x04}, + Value: big.NewInt(0), + GasLimit: 180000, + MaxFeePerGas: big.NewInt(100e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + } + + nonce := uint64(5) + invalidKey := []byte{0x01, 0x02, 0x03} // Too short + + _, err := builder.SignTransaction(tx, nonce, invalidKey) + + assert.Error(t, err) +} + +func TestTransactionBuilder_CustomSlippage(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + + config := DefaultTransactionBuilderConfig() + config.DefaultSlippageBPS = 100 // 1% slippage + + builder := NewTransactionBuilder(config, chainID, logger) + + opp := &arbitrage.Opportunity{ + ID: "test-opp-8", + Type: arbitrage.OpportunityTypeTwoPool, + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + OutputToken: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + OutputAmount: big.NewInt(1000e6), + Path: []arbitrage.SwapStep{ + { + Protocol: mevtypes.ProtocolUniswapV2, + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + AmountIn: big.NewInt(1e18), + AmountOut: big.NewInt(1000e6), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + }, + EstimatedGas: 150000, + } + + fromAddress := common.HexToAddress("0x0000000000000000000000000000000000000002") + + tx, err := builder.BuildTransaction(context.Background(), opp, fromAddress) + + require.NoError(t, err) + assert.Equal(t, uint16(100), tx.Slippage) + // MinOutput should be 990e6 (1% slippage on 1000e6) + assert.Equal(t, big.NewInt(990e6), tx.MinOutput) +} + +func TestTransactionBuilder_ZeroAmounts(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + opp := &arbitrage.Opportunity{ + ID: "test-opp-9", + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(0), + OutputToken: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + OutputAmount: big.NewInt(0), + Path: []arbitrage.SwapStep{ + { + Protocol: mevtypes.ProtocolUniswapV2, + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + AmountIn: big.NewInt(0), + AmountOut: big.NewInt(0), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + }, + EstimatedGas: 150000, + } + + fromAddress := common.HexToAddress("0x0000000000000000000000000000000000000002") + + tx, err := builder.BuildTransaction(context.Background(), opp, fromAddress) + + require.NoError(t, err) + assert.Equal(t, big.NewInt(0), tx.MinOutput) +} + +// Benchmark tests +func BenchmarkTransactionBuilder_BuildTransaction(b *testing.B) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + opp := &arbitrage.Opportunity{ + ID: "bench-opp", + Type: arbitrage.OpportunityTypeTwoPool, + InputToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + InputAmount: big.NewInt(1e18), + OutputToken: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + OutputAmount: big.NewInt(1500e6), + Path: []arbitrage.SwapStep{ + { + Protocol: mevtypes.ProtocolUniswapV2, + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + AmountIn: big.NewInt(1e18), + AmountOut: big.NewInt(1500e6), + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + }, + EstimatedGas: 150000, + } + + fromAddress := common.HexToAddress("0x0000000000000000000000000000000000000002") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = builder.BuildTransaction(context.Background(), opp, fromAddress) + } +} + +func BenchmarkTransactionBuilder_SignTransaction(b *testing.B) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + chainID := big.NewInt(42161) + builder := NewTransactionBuilder(nil, chainID, logger) + + privateKey, _ := crypto.GenerateKey() + + tx := &SwapTransaction{ + To: common.HexToAddress("0x0000000000000000000000000000000000000001"), + Data: []byte{0x01, 0x02, 0x03, 0x04}, + Value: big.NewInt(0), + GasLimit: 180000, + MaxFeePerGas: big.NewInt(100e9), + MaxPriorityFeePerGas: big.NewInt(2e9), + } + + nonce := uint64(5) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = builder.SignTransaction(tx, nonce, crypto.FromECDSA(privateKey)) + } +} diff --git a/pkg/execution/uniswap_v2_encoder_test.go b/pkg/execution/uniswap_v2_encoder_test.go new file mode 100644 index 0000000..64e15a3 --- /dev/null +++ b/pkg/execution/uniswap_v2_encoder_test.go @@ -0,0 +1,305 @@ +package execution + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewUniswapV2Encoder(t *testing.T) { + encoder := NewUniswapV2Encoder() + assert.NotNil(t, encoder) + assert.Equal(t, UniswapV2RouterAddress, encoder.routerAddress) +} + +func TestUniswapV2Encoder_EncodeSwap(t *testing.T) { + encoder := NewUniswapV2Encoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") // WETH + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") // USDC + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.Equal(t, encoder.routerAddress, to) + assert.NotEmpty(t, data) + + // Check method ID (first 4 bytes) + // swapExactTokensForTokens(uint256,uint256,address[],address,uint256) + assert.Len(t, data, 4+5*32+32+2*32) // methodID + 5 params + array length + 2 addresses + + // Verify method signature + expectedMethodID := []byte{0x38, 0xed, 0x17, 0x39} // swapExactTokensForTokens signature + assert.Equal(t, expectedMethodID, data[:4]) +} + +func TestUniswapV2Encoder_EncodeMultiHopSwap(t *testing.T) { + encoder := NewUniswapV2Encoder() + + path := []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), // WETH + common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), // USDC + common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), // WBTC + } + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1e7) + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeMultiHopSwap( + path, + amountIn, + minAmountOut, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.Equal(t, encoder.routerAddress, to) + assert.NotEmpty(t, data) + + // Verify method ID + expectedMethodID := []byte{0x38, 0xed, 0x17, 0x39} + assert.Equal(t, expectedMethodID, data[:4]) +} + +func TestUniswapV2Encoder_EncodeMultiHopSwap_EmptyPath(t *testing.T) { + encoder := NewUniswapV2Encoder() + + path := []common.Address{} + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1e7) + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + _, _, err := encoder.EncodeMultiHopSwap( + path, + amountIn, + minAmountOut, + recipient, + deadline, + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "path must contain at least 2 tokens") +} + +func TestUniswapV2Encoder_EncodeMultiHopSwap_SingleToken(t *testing.T) { + encoder := NewUniswapV2Encoder() + + path := []common.Address{ + common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + } + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1e7) + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + _, _, err := encoder.EncodeMultiHopSwap( + path, + amountIn, + minAmountOut, + recipient, + deadline, + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "path must contain at least 2 tokens") +} + +func TestUniswapV2Encoder_EncodeExactOutput(t *testing.T) { + encoder := NewUniswapV2Encoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountOut := big.NewInt(1500e6) + maxAmountIn := big.NewInt(2e18) + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeExactOutput( + tokenIn, + tokenOut, + amountOut, + maxAmountIn, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.Equal(t, encoder.routerAddress, to) + assert.NotEmpty(t, data) + + // Verify method ID for swapTokensForExactTokens + assert.Len(t, data, 4+5*32+32+2*32) +} + +func TestUniswapV2Encoder_ZeroAddresses(t *testing.T) { + encoder := NewUniswapV2Encoder() + + tokenIn := common.Address{} + tokenOut := common.Address{} + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.Address{} + recipient := common.Address{} + deadline := time.Now().Add(5 * time.Minute) + + // Should not error with zero addresses (validation done elsewhere) + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestUniswapV2Encoder_ZeroAmounts(t *testing.T) { + encoder := NewUniswapV2Encoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountIn := big.NewInt(0) + minAmountOut := big.NewInt(0) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + // Should not error with zero amounts (validation done elsewhere) + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestUniswapV2Encoder_LargeAmounts(t *testing.T) { + encoder := NewUniswapV2Encoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + + // Max uint256 + amountIn := new(big.Int) + amountIn.SetString("115792089237316195423570985008687907853269984665640564039457584007913129639935", 10) + minAmountOut := new(big.Int) + minAmountOut.SetString("115792089237316195423570985008687907853269984665640564039457584007913129639935", 10) + + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestUniswapV2Encoder_PastDeadline(t *testing.T) { + encoder := NewUniswapV2Encoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(-5 * time.Minute) // Past deadline + + // Should not error (validation done on-chain) + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestPadLeft(t *testing.T) { + tests := []struct { + name string + input []byte + length int + expected int + }{ + { + name: "Empty input", + input: []byte{}, + length: 32, + expected: 32, + }, + { + name: "Small number", + input: []byte{0x01}, + length: 32, + expected: 32, + }, + { + name: "Full size", + input: make([]byte, 32), + length: 32, + expected: 32, + }, + { + name: "Address", + input: make([]byte, 20), + length: 32, + expected: 32, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := padLeft(tt.input, tt.length) + assert.Len(t, result, tt.expected) + }) + } +} diff --git a/pkg/execution/uniswap_v3_encoder_test.go b/pkg/execution/uniswap_v3_encoder_test.go new file mode 100644 index 0000000..b171419 --- /dev/null +++ b/pkg/execution/uniswap_v3_encoder_test.go @@ -0,0 +1,484 @@ +package execution + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/your-org/mev-bot/pkg/arbitrage" + "github.com/your-org/mev-bot/pkg/cache" +) + +func TestNewUniswapV3Encoder(t *testing.T) { + encoder := NewUniswapV3Encoder() + assert.NotNil(t, encoder) + assert.Equal(t, UniswapV3SwapRouterAddress, encoder.swapRouterAddress) +} + +func TestUniswapV3Encoder_EncodeSwap(t *testing.T) { + encoder := NewUniswapV3Encoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") // WETH + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") // USDC + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + fee := uint32(3000) // 0.3% + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + fee, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.Equal(t, encoder.swapRouterAddress, to) + assert.NotEmpty(t, data) + + // Check method ID (first 4 bytes) + // exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160)) + assert.GreaterOrEqual(t, len(data), 4) +} + +func TestUniswapV3Encoder_EncodeMultiHopSwap(t *testing.T) { + encoder := NewUniswapV3Encoder() + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + Fee: 3000, + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + { + TokenIn: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + TokenOut: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + Fee: 3000, + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + }, + }, + } + + recipient := common.HexToAddress("0x0000000000000000000000000000000000000003") + minAmountOut := big.NewInt(1e7) + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeMultiHopSwap( + opp, + recipient, + minAmountOut, + deadline, + ) + + require.NoError(t, err) + assert.Equal(t, encoder.swapRouterAddress, to) + assert.NotEmpty(t, data) + + // Verify method ID for exactInput + assert.GreaterOrEqual(t, len(data), 4) +} + +func TestUniswapV3Encoder_EncodeMultiHopSwap_SingleStep(t *testing.T) { + encoder := NewUniswapV3Encoder() + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + Fee: 3000, + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + }, + } + + recipient := common.HexToAddress("0x0000000000000000000000000000000000000003") + minAmountOut := big.NewInt(1500e6) + deadline := time.Now().Add(5 * time.Minute) + + _, _, err := encoder.EncodeMultiHopSwap( + opp, + recipient, + minAmountOut, + deadline, + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "multi-hop requires at least 2 steps") +} + +func TestUniswapV3Encoder_EncodeMultiHopSwap_EmptyPath(t *testing.T) { + encoder := NewUniswapV3Encoder() + + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + Path: []arbitrage.SwapStep{}, + } + + recipient := common.HexToAddress("0x0000000000000000000000000000000000000003") + minAmountOut := big.NewInt(1500e6) + deadline := time.Now().Add(5 * time.Minute) + + _, _, err := encoder.EncodeMultiHopSwap( + opp, + recipient, + minAmountOut, + deadline, + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "multi-hop requires at least 2 steps") +} + +func TestUniswapV3Encoder_buildEncodedPath(t *testing.T) { + encoder := NewUniswapV3Encoder() + + opp := &arbitrage.Opportunity{ + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + Fee: 3000, + }, + { + TokenIn: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + TokenOut: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + Fee: 500, + }, + }, + } + + path := encoder.buildEncodedPath(opp) + + // Path should be: token (20) + fee (3) + token (20) + fee (3) + token (20) = 66 bytes + assert.Len(t, path, 66) + + // First 20 bytes should be first token + assert.Equal(t, opp.Path[0].TokenIn.Bytes(), path[:20]) + + // Bytes 20-23 should be first fee (3000 = 0x000BB8) + assert.Equal(t, []byte{0x00, 0x0B, 0xB8}, path[20:23]) + + // Bytes 23-43 should be second token + assert.Equal(t, opp.Path[0].TokenOut.Bytes(), path[23:43]) + + // Bytes 43-46 should be second fee (500 = 0x0001F4) + assert.Equal(t, []byte{0x00, 0x01, 0xF4}, path[43:46]) + + // Bytes 46-66 should be third token + assert.Equal(t, opp.Path[1].TokenOut.Bytes(), path[46:66]) +} + +func TestUniswapV3Encoder_buildEncodedPath_SingleStep(t *testing.T) { + encoder := NewUniswapV3Encoder() + + opp := &arbitrage.Opportunity{ + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + Fee: 3000, + }, + }, + } + + path := encoder.buildEncodedPath(opp) + + // Path should be: token (20) + fee (3) + token (20) = 43 bytes + assert.Len(t, path, 43) +} + +func TestUniswapV3Encoder_EncodeExactOutput(t *testing.T) { + encoder := NewUniswapV3Encoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountOut := big.NewInt(1500e6) + maxAmountIn := big.NewInt(2e18) + fee := uint32(3000) + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeExactOutput( + tokenIn, + tokenOut, + amountOut, + maxAmountIn, + fee, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.Equal(t, encoder.swapRouterAddress, to) + assert.NotEmpty(t, data) + assert.GreaterOrEqual(t, len(data), 4) +} + +func TestUniswapV3Encoder_EncodeMulticall(t *testing.T) { + encoder := NewUniswapV3Encoder() + + call1 := []byte{0x01, 0x02, 0x03, 0x04} + call2 := []byte{0x05, 0x06, 0x07, 0x08} + calls := [][]byte{call1, call2} + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeMulticall(calls, deadline) + + require.NoError(t, err) + assert.Equal(t, encoder.swapRouterAddress, to) + assert.NotEmpty(t, data) + assert.GreaterOrEqual(t, len(data), 4) +} + +func TestUniswapV3Encoder_EncodeMulticall_EmptyCalls(t *testing.T) { + encoder := NewUniswapV3Encoder() + + calls := [][]byte{} + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeMulticall(calls, deadline) + + require.NoError(t, err) + assert.Equal(t, encoder.swapRouterAddress, to) + assert.NotEmpty(t, data) +} + +func TestUniswapV3Encoder_EncodeMulticall_SingleCall(t *testing.T) { + encoder := NewUniswapV3Encoder() + + call := []byte{0x01, 0x02, 0x03, 0x04} + calls := [][]byte{call} + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeMulticall(calls, deadline) + + require.NoError(t, err) + assert.Equal(t, encoder.swapRouterAddress, to) + assert.NotEmpty(t, data) +} + +func TestUniswapV3Encoder_DifferentFees(t *testing.T) { + encoder := NewUniswapV3Encoder() + + fees := []uint32{ + 100, // 0.01% + 500, // 0.05% + 3000, // 0.3% + 10000, // 1% + } + + for _, fee := range fees { + t.Run(string(rune(fee)), func(t *testing.T) { + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + fee, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) + }) + } +} + +func TestUniswapV3Encoder_ZeroAmounts(t *testing.T) { + encoder := NewUniswapV3Encoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountIn := big.NewInt(0) + minAmountOut := big.NewInt(0) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + fee := uint32(3000) + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + fee, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestUniswapV3Encoder_LargeAmounts(t *testing.T) { + encoder := NewUniswapV3Encoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + + // Max uint256 + amountIn := new(big.Int) + amountIn.SetString("115792089237316195423570985008687907853269984665640564039457584007913129639935", 10) + minAmountOut := new(big.Int) + minAmountOut.SetString("115792089237316195423570985008687907853269984665640564039457584007913129639935", 10) + + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + fee := uint32(3000) + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + fee, + recipient, + deadline, + ) + + require.NoError(t, err) + assert.NotEmpty(t, to) + assert.NotEmpty(t, data) +} + +func TestUniswapV3Encoder_LongPath(t *testing.T) { + encoder := NewUniswapV3Encoder() + + // Create a 5-hop path + opp := &arbitrage.Opportunity{ + InputAmount: big.NewInt(1e18), + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + Fee: 3000, + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + { + TokenIn: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + TokenOut: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + Fee: 500, + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + }, + { + TokenIn: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + TokenOut: common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"), + Fee: 3000, + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000003"), + }, + { + TokenIn: common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"), + TokenOut: common.HexToAddress("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"), + Fee: 500, + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000004"), + }, + { + TokenIn: common.HexToAddress("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"), + TokenOut: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + Fee: 3000, + PoolAddress: common.HexToAddress("0x0000000000000000000000000000000000000005"), + }, + }, + } + + recipient := common.HexToAddress("0x0000000000000000000000000000000000000003") + minAmountOut := big.NewInt(1e7) + deadline := time.Now().Add(5 * time.Minute) + + to, data, err := encoder.EncodeMultiHopSwap( + opp, + recipient, + minAmountOut, + deadline, + ) + + require.NoError(t, err) + assert.Equal(t, encoder.swapRouterAddress, to) + assert.NotEmpty(t, data) + + // Path should be: 20 + (23 * 5) = 135 bytes + path := encoder.buildEncodedPath(opp) + assert.Len(t, path, 135) +} + +// Benchmark tests +func BenchmarkUniswapV3Encoder_EncodeSwap(b *testing.B) { + encoder := NewUniswapV3Encoder() + + tokenIn := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + tokenOut := common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8") + amountIn := big.NewInt(1e18) + minAmountOut := big.NewInt(1500e6) + poolAddress := common.HexToAddress("0x0000000000000000000000000000000000000001") + fee := uint32(3000) + recipient := common.HexToAddress("0x0000000000000000000000000000000000000002") + deadline := time.Now().Add(5 * time.Minute) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = encoder.EncodeSwap( + tokenIn, + tokenOut, + amountIn, + minAmountOut, + poolAddress, + fee, + recipient, + deadline, + ) + } +} + +func BenchmarkUniswapV3Encoder_buildEncodedPath(b *testing.B) { + encoder := NewUniswapV3Encoder() + + opp := &arbitrage.Opportunity{ + Path: []arbitrage.SwapStep{ + { + TokenIn: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), + TokenOut: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + Fee: 3000, + }, + { + TokenIn: common.HexToAddress("0xFF970a61A04b1cA14834A43f5dE4533eBDDB5CC8"), + TokenOut: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), + Fee: 500, + }, + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = encoder.buildEncodedPath(opp) + } +}