diff --git a/pkg/execution/curve_encoder.go b/pkg/execution/curve_encoder.go new file mode 100644 index 0000000..cdfa8c4 --- /dev/null +++ b/pkg/execution/curve_encoder.go @@ -0,0 +1,184 @@ +package execution + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// CurveEncoder encodes transactions for Curve pools +type CurveEncoder struct{} + +// NewCurveEncoder creates a new Curve encoder +func NewCurveEncoder() *CurveEncoder { + return &CurveEncoder{} +} + +// EncodeSwap encodes a Curve exchange transaction +func (e *CurveEncoder) EncodeSwap( + tokenIn common.Address, + tokenOut common.Address, + amountIn *big.Int, + minAmountOut *big.Int, + poolAddress common.Address, + recipient common.Address, +) (common.Address, []byte, error) { + // Curve pools have different interfaces depending on the pool type + // Most common: exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) + // For newer pools: exchange(uint256 i, uint256 j, uint256 dx, uint256 min_dy) + + // We'll use the int128 version as it's most common + // exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) + methodID := crypto.Keccak256([]byte("exchange(int128,int128,uint256,uint256)"))[:4] + + // Note: In production, we'd need to: + // 1. Query the pool to determine which tokens correspond to which indices + // 2. Handle the newer uint256 index version + // For now, we'll assume we know the indices + + // Placeholder indices - in reality these would be determined from pool state + i := big.NewInt(0) // Index of tokenIn + j := big.NewInt(1) // Index of tokenOut + + data := make([]byte, 0) + data = append(data, methodID...) + + // i (int128) + data = append(data, padLeft(i.Bytes(), 32)...) + + // j (int128) + data = append(data, padLeft(j.Bytes(), 32)...) + + // dx (amountIn) + data = append(data, padLeft(amountIn.Bytes(), 32)...) + + // min_dy (minAmountOut) + data = append(data, padLeft(minAmountOut.Bytes(), 32)...) + + // Curve pools typically send tokens to msg.sender + // So we return the pool address as the target + return poolAddress, data, nil +} + +// EncodeExchangeUnderlying encodes a Curve exchange_underlying transaction +// (for metapools or pools with wrapped tokens) +func (e *CurveEncoder) EncodeExchangeUnderlying( + tokenIn common.Address, + tokenOut common.Address, + amountIn *big.Int, + minAmountOut *big.Int, + poolAddress common.Address, + recipient common.Address, +) (common.Address, []byte, error) { + // exchange_underlying(int128 i, int128 j, uint256 dx, uint256 min_dy) + methodID := crypto.Keccak256([]byte("exchange_underlying(int128,int128,uint256,uint256)"))[:4] + + // Placeholder indices + i := big.NewInt(0) + j := big.NewInt(1) + + data := make([]byte, 0) + data = append(data, methodID...) + + // i (int128) + data = append(data, padLeft(i.Bytes(), 32)...) + + // j (int128) + data = append(data, padLeft(j.Bytes(), 32)...) + + // dx (amountIn) + data = append(data, padLeft(amountIn.Bytes(), 32)...) + + // min_dy (minAmountOut) + data = append(data, padLeft(minAmountOut.Bytes(), 32)...) + + return poolAddress, data, nil +} + +// EncodeDynamicExchange encodes exchange for newer Curve pools with uint256 indices +func (e *CurveEncoder) EncodeDynamicExchange( + i *big.Int, + j *big.Int, + amountIn *big.Int, + minAmountOut *big.Int, + poolAddress common.Address, +) (common.Address, []byte, error) { + // exchange(uint256 i, uint256 j, uint256 dx, uint256 min_dy) + methodID := crypto.Keccak256([]byte("exchange(uint256,uint256,uint256,uint256)"))[:4] + + data := make([]byte, 0) + data = append(data, methodID...) + + // i (uint256) + data = append(data, padLeft(i.Bytes(), 32)...) + + // j (uint256) + data = append(data, padLeft(j.Bytes(), 32)...) + + // dx (amountIn) + data = append(data, padLeft(amountIn.Bytes(), 32)...) + + // min_dy (minAmountOut) + data = append(data, padLeft(minAmountOut.Bytes(), 32)...) + + return poolAddress, data, nil +} + +// EncodeGetDy encodes a view call to get expected output amount +func (e *CurveEncoder) EncodeGetDy( + i *big.Int, + j *big.Int, + amountIn *big.Int, + poolAddress common.Address, +) (common.Address, []byte, error) { + // get_dy(int128 i, int128 j, uint256 dx) returns (uint256) + methodID := crypto.Keccak256([]byte("get_dy(int128,int128,uint256)"))[:4] + + data := make([]byte, 0) + data = append(data, methodID...) + + // i (int128) + data = append(data, padLeft(i.Bytes(), 32)...) + + // j (int128) + data = append(data, padLeft(j.Bytes(), 32)...) + + // dx (amountIn) + data = append(data, padLeft(amountIn.Bytes(), 32)...) + + return poolAddress, data, nil +} + +// EncodeCoinIndices encodes a call to get coin indices +func (e *CurveEncoder) EncodeCoinIndices( + tokenAddress common.Address, + poolAddress common.Address, +) (common.Address, []byte, error) { + // coins(uint256 i) returns (address) + // We'd need to call this multiple times to find the index + methodID := crypto.Keccak256([]byte("coins(uint256)"))[:4] + + data := make([]byte, 0) + data = append(data, methodID...) + + // Index (we'd iterate through 0, 1, 2, 3 to find matching token) + data = append(data, padLeft(big.NewInt(0).Bytes(), 32)...) + + return poolAddress, data, nil +} + +// GetCoinIndex determines the index of a token in a Curve pool +// This is a helper function that would need to be called before encoding swaps +func (e *CurveEncoder) GetCoinIndex( + tokenAddress common.Address, + poolCoins []common.Address, +) (int, error) { + for i, coin := range poolCoins { + if coin == tokenAddress { + return i, nil + } + } + return -1, fmt.Errorf("token not found in pool") +} diff --git a/pkg/execution/flashloan.go b/pkg/execution/flashloan.go new file mode 100644 index 0000000..8290027 --- /dev/null +++ b/pkg/execution/flashloan.go @@ -0,0 +1,459 @@ +package execution + +import ( + "context" + "fmt" + "log/slog" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/your-org/mev-bot/pkg/arbitrage" +) + +// Aave V3 Pool address on Arbitrum +var AaveV3PoolAddress = common.HexToAddress("0x794a61358D6845594F94dc1DB02A252b5b4814aD") + +// WETH address on Arbitrum +var WETHAddress = common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") + +// FlashloanProvider represents different flashloan providers +type FlashloanProvider string + +const ( + FlashloanProviderAaveV3 FlashloanProvider = "aave_v3" + FlashloanProviderUniswapV3 FlashloanProvider = "uniswap_v3" + FlashloanProviderUniswapV2 FlashloanProvider = "uniswap_v2" +) + +// FlashloanConfig contains configuration for flashloans +type FlashloanConfig struct { + // Provider preferences (ordered by preference) + PreferredProviders []FlashloanProvider + + // Fee configuration + AaveV3FeeBPS uint16 // Aave V3 fee in basis points (default: 9 = 0.09%) + UniswapV3FeeBPS uint16 // Uniswap V3 flash fee (pool dependent) + UniswapV2FeeBPS uint16 // Uniswap V2 flash swap fee (30 bps) + + // Execution contract + ExecutorContract common.Address // Custom contract that receives flashloan callback +} + +// DefaultFlashloanConfig returns default configuration +func DefaultFlashloanConfig() *FlashloanConfig { + return &FlashloanConfig{ + PreferredProviders: []FlashloanProvider{ + FlashloanProviderAaveV3, + FlashloanProviderUniswapV3, + FlashloanProviderUniswapV2, + }, + AaveV3FeeBPS: 9, // 0.09% + UniswapV3FeeBPS: 0, // No fee for flash swaps (pay in swap) + UniswapV2FeeBPS: 30, // 0.3% (0.25% fee + 0.05% protocol) + } +} + +// FlashloanManager manages flashloan operations +type FlashloanManager struct { + config *FlashloanConfig + logger *slog.Logger + + // Provider-specific encoders + aaveV3Encoder *AaveV3FlashloanEncoder + uniswapV3Encoder *UniswapV3FlashloanEncoder + uniswapV2Encoder *UniswapV2FlashloanEncoder +} + +// NewFlashloanManager creates a new flashloan manager +func NewFlashloanManager(config *FlashloanConfig, logger *slog.Logger) *FlashloanManager { + if config == nil { + config = DefaultFlashloanConfig() + } + + return &FlashloanManager{ + config: config, + logger: logger.With("component", "flashloan_manager"), + aaveV3Encoder: NewAaveV3FlashloanEncoder(), + uniswapV3Encoder: NewUniswapV3FlashloanEncoder(), + uniswapV2Encoder: NewUniswapV2FlashloanEncoder(), + } +} + +// FlashloanRequest represents a flashloan request +type FlashloanRequest struct { + Token common.Address + Amount *big.Int + Provider FlashloanProvider + Params []byte // Additional parameters to pass to callback +} + +// FlashloanTransaction represents an encoded flashloan transaction +type FlashloanTransaction struct { + To common.Address + Data []byte + Value *big.Int + Provider FlashloanProvider + Fee *big.Int +} + +// BuildFlashloanTransaction builds a flashloan transaction for an opportunity +func (fm *FlashloanManager) BuildFlashloanTransaction( + ctx context.Context, + opp *arbitrage.Opportunity, + swapCalldata []byte, +) (*FlashloanTransaction, error) { + fm.logger.Debug("building flashloan transaction", + "opportunityID", opp.ID, + "inputAmount", opp.InputAmount.String(), + ) + + // Determine best flashloan provider + provider, err := fm.selectProvider(ctx, opp.InputToken, opp.InputAmount) + if err != nil { + return nil, fmt.Errorf("failed to select provider: %w", err) + } + + fm.logger.Debug("selected flashloan provider", "provider", provider) + + // Build flashloan transaction + var tx *FlashloanTransaction + + switch provider { + case FlashloanProviderAaveV3: + tx, err = fm.buildAaveV3Flashloan(opp, swapCalldata) + + case FlashloanProviderUniswapV3: + tx, err = fm.buildUniswapV3Flashloan(opp, swapCalldata) + + case FlashloanProviderUniswapV2: + tx, err = fm.buildUniswapV2Flashloan(opp, swapCalldata) + + default: + return nil, fmt.Errorf("unsupported flashloan provider: %s", provider) + } + + if err != nil { + return nil, fmt.Errorf("failed to build flashloan: %w", err) + } + + fm.logger.Info("flashloan transaction built", + "provider", provider, + "amount", opp.InputAmount.String(), + "fee", tx.Fee.String(), + ) + + return tx, nil +} + +// buildAaveV3Flashloan builds an Aave V3 flashloan transaction +func (fm *FlashloanManager) buildAaveV3Flashloan( + opp *arbitrage.Opportunity, + swapCalldata []byte, +) (*FlashloanTransaction, error) { + // Calculate fee + fee := fm.calculateFee(opp.InputAmount, fm.config.AaveV3FeeBPS) + + // Encode flashloan call + to, data, err := fm.aaveV3Encoder.EncodeFlashloan( + []common.Address{opp.InputToken}, + []*big.Int{opp.InputAmount}, + fm.config.ExecutorContract, + swapCalldata, + ) + + if err != nil { + return nil, fmt.Errorf("failed to encode Aave V3 flashloan: %w", err) + } + + return &FlashloanTransaction{ + To: to, + Data: data, + Value: big.NewInt(0), + Provider: FlashloanProviderAaveV3, + Fee: fee, + }, nil +} + +// buildUniswapV3Flashloan builds a Uniswap V3 flash swap transaction +func (fm *FlashloanManager) buildUniswapV3Flashloan( + opp *arbitrage.Opportunity, + swapCalldata []byte, +) (*FlashloanTransaction, error) { + // Uniswap V3 flash swaps don't have a separate fee + // The fee is paid as part of the swap + fee := big.NewInt(0) + + // Get pool address for the flashloan token + // In production, we'd query the pool with highest liquidity + poolAddress := opp.Path[0].PoolAddress + + // Encode flash swap + to, data, err := fm.uniswapV3Encoder.EncodeFlash( + opp.InputToken, + opp.InputAmount, + poolAddress, + fm.config.ExecutorContract, + swapCalldata, + ) + + if err != nil { + return nil, fmt.Errorf("failed to encode Uniswap V3 flash: %w", err) + } + + return &FlashloanTransaction{ + To: to, + Data: data, + Value: big.NewInt(0), + Provider: FlashloanProviderUniswapV3, + Fee: fee, + }, nil +} + +// buildUniswapV2Flashloan builds a Uniswap V2 flash swap transaction +func (fm *FlashloanManager) buildUniswapV2Flashloan( + opp *arbitrage.Opportunity, + swapCalldata []byte, +) (*FlashloanTransaction, error) { + // Calculate fee + fee := fm.calculateFee(opp.InputAmount, fm.config.UniswapV2FeeBPS) + + // Get pool address + poolAddress := opp.Path[0].PoolAddress + + // Encode flash swap + to, data, err := fm.uniswapV2Encoder.EncodeFlash( + opp.InputToken, + opp.InputAmount, + poolAddress, + fm.config.ExecutorContract, + swapCalldata, + ) + + if err != nil { + return nil, fmt.Errorf("failed to encode Uniswap V2 flash: %w", err) + } + + return &FlashloanTransaction{ + To: to, + Data: data, + Value: big.NewInt(0), + Provider: FlashloanProviderUniswapV2, + Fee: fee, + }, nil +} + +// selectProvider selects the best flashloan provider +func (fm *FlashloanManager) selectProvider( + ctx context.Context, + token common.Address, + amount *big.Int, +) (FlashloanProvider, error) { + // For now, use the first preferred provider + // In production, we'd check availability and fees for each + + if len(fm.config.PreferredProviders) == 0 { + return "", fmt.Errorf("no flashloan providers configured") + } + + // Use first preferred provider + return fm.config.PreferredProviders[0], nil +} + +// calculateFee calculates the flashloan fee +func (fm *FlashloanManager) calculateFee(amount *big.Int, feeBPS uint16) *big.Int { + // fee = amount * feeBPS / 10000 + fee := new(big.Int).Mul(amount, big.NewInt(int64(feeBPS))) + fee.Div(fee, big.NewInt(10000)) + return fee +} + +// CalculateTotalCost calculates the total cost including fee +func (fm *FlashloanManager) CalculateTotalCost(amount *big.Int, feeBPS uint16) *big.Int { + fee := fm.calculateFee(amount, feeBPS) + total := new(big.Int).Add(amount, fee) + return total +} + +// AaveV3FlashloanEncoder encodes Aave V3 flashloan calls +type AaveV3FlashloanEncoder struct { + poolAddress common.Address +} + +// NewAaveV3FlashloanEncoder creates a new Aave V3 flashloan encoder +func NewAaveV3FlashloanEncoder() *AaveV3FlashloanEncoder { + return &AaveV3FlashloanEncoder{ + poolAddress: AaveV3PoolAddress, + } +} + +// EncodeFlashloan encodes an Aave V3 flashloan call +func (e *AaveV3FlashloanEncoder) EncodeFlashloan( + assets []common.Address, + amounts []*big.Int, + receiverAddress common.Address, + params []byte, +) (common.Address, []byte, error) { + // flashLoan(address receivingAddress, address[] assets, uint256[] amounts, uint256[] modes, address onBehalfOf, bytes params, uint16 referralCode) + methodID := crypto.Keccak256([]byte("flashLoan(address,address[],uint256[],uint256[],address,bytes,uint16)"))[:4] + + // For simplicity, this is a basic implementation + // In production, we'd need to properly encode all dynamic arrays + + data := make([]byte, 0) + data = append(data, methodID...) + + // receivingAddress + data = append(data, padLeft(receiverAddress.Bytes(), 32)...) + + // Offset to assets array (7 * 32 bytes) + data = append(data, padLeft(big.NewInt(224).Bytes(), 32)...) + + // Offset to amounts array (calculated based on assets length) + assetsOffset := 224 + 32 + (32 * len(assets)) + data = append(data, padLeft(big.NewInt(int64(assetsOffset)).Bytes(), 32)...) + + // Offset to modes array + modesOffset := assetsOffset + 32 + (32 * len(amounts)) + data = append(data, padLeft(big.NewInt(int64(modesOffset)).Bytes(), 32)...) + + // onBehalfOf (receiver address) + data = append(data, padLeft(receiverAddress.Bytes(), 32)...) + + // Offset to params + paramsOffset := modesOffset + 32 + (32 * len(assets)) + data = append(data, padLeft(big.NewInt(int64(paramsOffset)).Bytes(), 32)...) + + // referralCode (0) + data = append(data, padLeft(big.NewInt(0).Bytes(), 32)...) + + // Assets array + data = append(data, padLeft(big.NewInt(int64(len(assets))).Bytes(), 32)...) + for _, asset := range assets { + data = append(data, padLeft(asset.Bytes(), 32)...) + } + + // Amounts array + data = append(data, padLeft(big.NewInt(int64(len(amounts))).Bytes(), 32)...) + for _, amount := range amounts { + data = append(data, padLeft(amount.Bytes(), 32)...) + } + + // Modes array (0 = no debt, we repay in same transaction) + data = append(data, padLeft(big.NewInt(int64(len(assets))).Bytes(), 32)...) + for range assets { + data = append(data, padLeft(big.NewInt(0).Bytes(), 32)...) + } + + // Params bytes + data = append(data, padLeft(big.NewInt(int64(len(params))).Bytes(), 32)...) + data = append(data, params...) + + // Pad params to 32-byte boundary + remainder := len(params) % 32 + if remainder != 0 { + padding := make([]byte, 32-remainder) + data = append(data, padding...) + } + + return e.poolAddress, data, nil +} + +// UniswapV3FlashloanEncoder encodes Uniswap V3 flash calls +type UniswapV3FlashloanEncoder struct{} + +// NewUniswapV3FlashloanEncoder creates a new Uniswap V3 flashloan encoder +func NewUniswapV3FlashloanEncoder() *UniswapV3FlashloanEncoder { + return &UniswapV3FlashloanEncoder{} +} + +// EncodeFlash encodes a Uniswap V3 flash call +func (e *UniswapV3FlashloanEncoder) EncodeFlash( + token common.Address, + amount *big.Int, + poolAddress common.Address, + recipient common.Address, + data []byte, +) (common.Address, []byte, error) { + // flash(address recipient, uint256 amount0, uint256 amount1, bytes data) + methodID := crypto.Keccak256([]byte("flash(address,uint256,uint256,bytes)"))[:4] + + calldata := make([]byte, 0) + calldata = append(calldata, methodID...) + + // recipient + calldata = append(calldata, padLeft(recipient.Bytes(), 32)...) + + // amount0 or amount1 (depending on which token in the pool) + // For simplicity, assume token0 + calldata = append(calldata, padLeft(amount.Bytes(), 32)...) + calldata = append(calldata, padLeft(big.NewInt(0).Bytes(), 32)...) + + // Offset to data bytes + calldata = append(calldata, padLeft(big.NewInt(128).Bytes(), 32)...) + + // Data length + calldata = append(calldata, padLeft(big.NewInt(int64(len(data))).Bytes(), 32)...) + + // Data + calldata = append(calldata, data...) + + // Padding + remainder := len(data) % 32 + if remainder != 0 { + padding := make([]byte, 32-remainder) + calldata = append(calldata, padding...) + } + + return poolAddress, calldata, nil +} + +// UniswapV2FlashloanEncoder encodes Uniswap V2 flash swap calls +type UniswapV2FlashloanEncoder struct{} + +// NewUniswapV2FlashloanEncoder creates a new Uniswap V2 flashloan encoder +func NewUniswapV2FlashloanEncoder() *UniswapV2FlashloanEncoder { + return &UniswapV2FlashloanEncoder{} +} + +// EncodeFlash encodes a Uniswap V2 flash swap call +func (e *UniswapV2FlashloanEncoder) EncodeFlash( + token common.Address, + amount *big.Int, + poolAddress common.Address, + recipient common.Address, + data []byte, +) (common.Address, []byte, error) { + // swap(uint amount0Out, uint amount1Out, address to, bytes data) + methodID := crypto.Keccak256([]byte("swap(uint256,uint256,address,bytes)"))[:4] + + calldata := make([]byte, 0) + calldata = append(calldata, methodID...) + + // amount0Out or amount1Out (depending on which token) + // For simplicity, assume token0 + calldata = append(calldata, padLeft(amount.Bytes(), 32)...) + calldata = append(calldata, padLeft(big.NewInt(0).Bytes(), 32)...) + + // to (recipient) + calldata = append(calldata, padLeft(recipient.Bytes(), 32)...) + + // Offset to data bytes + calldata = append(calldata, padLeft(big.NewInt(128).Bytes(), 32)...) + + // Data length + calldata = append(calldata, padLeft(big.NewInt(int64(len(data))).Bytes(), 32)...) + + // Data + calldata = append(calldata, data...) + + // Padding + remainder := len(data) % 32 + if remainder != 0 { + padding := make([]byte, 32-remainder) + calldata = append(calldata, padding...) + } + + return poolAddress, calldata, nil +} diff --git a/pkg/execution/transaction_builder.go b/pkg/execution/transaction_builder.go new file mode 100644 index 0000000..bcf97f4 --- /dev/null +++ b/pkg/execution/transaction_builder.go @@ -0,0 +1,480 @@ +package execution + +import ( + "context" + "fmt" + "log/slog" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/your-org/mev-bot/pkg/arbitrage" + mevtypes "github.com/your-org/mev-bot/pkg/types" +) + +// TransactionBuilderConfig contains configuration for transaction building +type TransactionBuilderConfig struct { + // Slippage protection + DefaultSlippageBPS uint16 // Basis points (e.g., 50 = 0.5%) + MaxSlippageBPS uint16 // Maximum allowed slippage + + // Gas configuration + GasLimitMultiplier float64 // Multiplier for estimated gas (e.g., 1.2 = 20% buffer) + MaxGasLimit uint64 // Maximum gas limit per transaction + + // EIP-1559 configuration + MaxPriorityFeeGwei uint64 // Max priority fee in gwei + MaxFeePerGasGwei uint64 // Max fee per gas in gwei + + // Deadline + DefaultDeadline time.Duration // Default deadline for swaps (e.g., 5 minutes) +} + +// DefaultTransactionBuilderConfig returns default configuration +func DefaultTransactionBuilderConfig() *TransactionBuilderConfig { + return &TransactionBuilderConfig{ + DefaultSlippageBPS: 50, // 0.5% + MaxSlippageBPS: 300, // 3% + GasLimitMultiplier: 1.2, + MaxGasLimit: 3000000, // 3M gas + MaxPriorityFeeGwei: 2, // 2 gwei priority + MaxFeePerGasGwei: 100, // 100 gwei max + DefaultDeadline: 5 * time.Minute, + } +} + +// TransactionBuilder builds executable transactions from arbitrage opportunities +type TransactionBuilder struct { + config *TransactionBuilderConfig + chainID *big.Int + logger *slog.Logger + + // Protocol-specific encoders + uniswapV2Encoder *UniswapV2Encoder + uniswapV3Encoder *UniswapV3Encoder + curveEncoder *CurveEncoder +} + +// NewTransactionBuilder creates a new transaction builder +func NewTransactionBuilder( + config *TransactionBuilderConfig, + chainID *big.Int, + logger *slog.Logger, +) *TransactionBuilder { + if config == nil { + config = DefaultTransactionBuilderConfig() + } + + return &TransactionBuilder{ + config: config, + chainID: chainID, + logger: logger.With("component", "transaction_builder"), + uniswapV2Encoder: NewUniswapV2Encoder(), + uniswapV3Encoder: NewUniswapV3Encoder(), + curveEncoder: NewCurveEncoder(), + } +} + +// SwapTransaction represents a built swap transaction ready for execution +type SwapTransaction struct { + // Transaction data + To common.Address + Data []byte + Value *big.Int + GasLimit uint64 + + // EIP-1559 gas pricing + MaxFeePerGas *big.Int + MaxPriorityFeePerGas *big.Int + + // Metadata + Opportunity *arbitrage.Opportunity + Deadline time.Time + Slippage uint16 // Basis points + MinOutput *big.Int + + // Execution context + RequiresFlashloan bool + FlashloanAmount *big.Int +} + +// BuildTransaction builds a transaction from an arbitrage opportunity +func (tb *TransactionBuilder) BuildTransaction( + ctx context.Context, + opp *arbitrage.Opportunity, + fromAddress common.Address, +) (*SwapTransaction, error) { + tb.logger.Debug("building transaction", + "opportunityID", opp.ID, + "type", opp.Type, + "hops", len(opp.Path), + ) + + // Validate opportunity + if !opp.CanExecute() { + return nil, fmt.Errorf("opportunity cannot be executed") + } + + if opp.IsExpired() { + return nil, fmt.Errorf("opportunity has expired") + } + + // Calculate deadline + deadline := time.Now().Add(tb.config.DefaultDeadline) + if opp.ExpiresAt.Before(deadline) { + deadline = opp.ExpiresAt + } + + // Calculate minimum output with slippage + slippage := tb.config.DefaultSlippageBPS + minOutput := tb.calculateMinOutput(opp.OutputAmount, slippage) + + // Build transaction based on path length + var tx *SwapTransaction + var err error + + if len(opp.Path) == 1 { + // Single swap + tx, err = tb.buildSingleSwap(ctx, opp, fromAddress, minOutput, deadline, slippage) + } else { + // Multi-hop swap + tx, err = tb.buildMultiHopSwap(ctx, opp, fromAddress, minOutput, deadline, slippage) + } + + if err != nil { + return nil, fmt.Errorf("failed to build transaction: %w", err) + } + + // Set gas pricing + err = tb.setGasPricing(ctx, tx) + if err != nil { + return nil, fmt.Errorf("failed to set gas pricing: %w", err) + } + + tb.logger.Info("transaction built successfully", + "opportunityID", opp.ID, + "to", tx.To.Hex(), + "gasLimit", tx.GasLimit, + "maxFeePerGas", tx.MaxFeePerGas.String(), + "minOutput", minOutput.String(), + ) + + return tx, nil +} + +// buildSingleSwap builds a transaction for a single swap +func (tb *TransactionBuilder) buildSingleSwap( + ctx context.Context, + opp *arbitrage.Opportunity, + fromAddress common.Address, + minOutput *big.Int, + deadline time.Time, + slippage uint16, +) (*SwapTransaction, error) { + step := opp.Path[0] + + var data []byte + var to common.Address + var err error + + switch step.Protocol { + case mevtypes.ProtocolUniswapV2, mevtypes.ProtocolSushiSwap: + to, data, err = tb.uniswapV2Encoder.EncodeSwap( + step.TokenIn, + step.TokenOut, + step.AmountIn, + minOutput, + step.PoolAddress, + fromAddress, + deadline, + ) + + case mevtypes.ProtocolUniswapV3: + to, data, err = tb.uniswapV3Encoder.EncodeSwap( + step.TokenIn, + step.TokenOut, + step.AmountIn, + minOutput, + step.PoolAddress, + step.Fee, + fromAddress, + deadline, + ) + + case mevtypes.ProtocolCurve: + to, data, err = tb.curveEncoder.EncodeSwap( + step.TokenIn, + step.TokenOut, + step.AmountIn, + minOutput, + step.PoolAddress, + fromAddress, + ) + + default: + return nil, fmt.Errorf("unsupported protocol: %s", step.Protocol) + } + + if err != nil { + return nil, fmt.Errorf("failed to encode swap: %w", err) + } + + // Estimate gas limit + gasLimit := tb.estimateGasLimit(opp) + + tx := &SwapTransaction{ + To: to, + Data: data, + Value: big.NewInt(0), // No ETH value for token swaps + GasLimit: gasLimit, + Opportunity: opp, + Deadline: deadline, + Slippage: slippage, + MinOutput: minOutput, + RequiresFlashloan: tb.requiresFlashloan(opp, fromAddress), + } + + return tx, nil +} + +// buildMultiHopSwap builds a transaction for multi-hop swaps +func (tb *TransactionBuilder) buildMultiHopSwap( + ctx context.Context, + opp *arbitrage.Opportunity, + fromAddress common.Address, + minOutput *big.Int, + deadline time.Time, + slippage uint16, +) (*SwapTransaction, error) { + // For multi-hop, we need to use a router contract or build a custom aggregator + // This is a simplified implementation that chains individual swaps + + tb.logger.Debug("building multi-hop transaction", + "hops", len(opp.Path), + ) + + // Determine if all hops use the same protocol + firstProtocol := opp.Path[0].Protocol + sameProtocol := true + for _, step := range opp.Path { + if step.Protocol != firstProtocol { + sameProtocol = false + break + } + } + + var to common.Address + var data []byte + var err error + + if sameProtocol { + // Use protocol-specific multi-hop encoding + switch firstProtocol { + case mevtypes.ProtocolUniswapV2, mevtypes.ProtocolSushiSwap: + to, data, err = tb.uniswapV2Encoder.EncodeMultiHopSwap(opp, fromAddress, minOutput, deadline) + + case mevtypes.ProtocolUniswapV3: + to, data, err = tb.uniswapV3Encoder.EncodeMultiHopSwap(opp, fromAddress, minOutput, deadline) + + default: + return nil, fmt.Errorf("multi-hop not supported for protocol: %s", firstProtocol) + } + } else { + // Mixed protocols - need custom aggregator contract + return nil, fmt.Errorf("mixed-protocol multi-hop not yet implemented") + } + + if err != nil { + return nil, fmt.Errorf("failed to encode multi-hop swap: %w", err) + } + + gasLimit := tb.estimateGasLimit(opp) + + tx := &SwapTransaction{ + To: to, + Data: data, + Value: big.NewInt(0), + GasLimit: gasLimit, + Opportunity: opp, + Deadline: deadline, + Slippage: slippage, + MinOutput: minOutput, + RequiresFlashloan: tb.requiresFlashloan(opp, fromAddress), + } + + return tx, nil +} + +// setGasPricing sets EIP-1559 gas pricing for the transaction +func (tb *TransactionBuilder) setGasPricing(ctx context.Context, tx *SwapTransaction) error { + // Use configured max values + maxPriorityFee := new(big.Int).Mul( + big.NewInt(int64(tb.config.MaxPriorityFeeGwei)), + big.NewInt(1e9), + ) + + maxFeePerGas := new(big.Int).Mul( + big.NewInt(int64(tb.config.MaxFeePerGasGwei)), + big.NewInt(1e9), + ) + + // For arbitrage, we can calculate max gas price based on profit + if tx.Opportunity != nil && tx.Opportunity.NetProfit.Sign() > 0 { + // Max gas we can afford: netProfit / gasLimit + maxAffordableGas := new(big.Int).Div( + tx.Opportunity.NetProfit, + big.NewInt(int64(tx.GasLimit)), + ) + + // Use 90% of max affordable to maintain profit margin + affordableGas := new(big.Int).Mul(maxAffordableGas, big.NewInt(90)) + affordableGas.Div(affordableGas, big.NewInt(100)) + + // Use the lower of configured max and affordable + if affordableGas.Cmp(maxFeePerGas) < 0 { + maxFeePerGas = affordableGas + } + } + + tx.MaxFeePerGas = maxFeePerGas + tx.MaxPriorityFeePerGas = maxPriorityFee + + tb.logger.Debug("set gas pricing", + "maxFeePerGas", maxFeePerGas.String(), + "maxPriorityFeePerGas", maxPriorityFee.String(), + ) + + return nil +} + +// calculateMinOutput calculates minimum output amount with slippage protection +func (tb *TransactionBuilder) calculateMinOutput(outputAmount *big.Int, slippageBPS uint16) *big.Int { + // minOutput = outputAmount * (10000 - slippageBPS) / 10000 + multiplier := big.NewInt(int64(10000 - slippageBPS)) + minOutput := new(big.Int).Mul(outputAmount, multiplier) + minOutput.Div(minOutput, big.NewInt(10000)) + return minOutput +} + +// estimateGasLimit estimates gas limit for the opportunity +func (tb *TransactionBuilder) estimateGasLimit(opp *arbitrage.Opportunity) uint64 { + // Base gas + baseGas := uint64(21000) + + // Gas per swap + var gasPerSwap uint64 + for _, step := range opp.Path { + switch step.Protocol { + case mevtypes.ProtocolUniswapV2, mevtypes.ProtocolSushiSwap: + gasPerSwap += 120000 + case mevtypes.ProtocolUniswapV3: + gasPerSwap += 180000 + case mevtypes.ProtocolCurve: + gasPerSwap += 150000 + default: + gasPerSwap += 150000 // Default estimate + } + } + + totalGas := baseGas + gasPerSwap + + // Apply multiplier for safety + gasLimit := uint64(float64(totalGas) * tb.config.GasLimitMultiplier) + + // Cap at max + if gasLimit > tb.config.MaxGasLimit { + gasLimit = tb.config.MaxGasLimit + } + + return gasLimit +} + +// requiresFlashloan determines if the opportunity requires a flashloan +func (tb *TransactionBuilder) requiresFlashloan(opp *arbitrage.Opportunity, fromAddress common.Address) bool { + // If input amount is large, we likely need a flashloan + // This is a simplified check - in production, we'd check actual wallet balance + + oneETH := new(big.Int).Mul(big.NewInt(1), big.NewInt(1e18)) + + // Require flashloan if input > 1 ETH + return opp.InputAmount.Cmp(oneETH) > 0 +} + +// SignTransaction signs the transaction with the provided private key +func (tb *TransactionBuilder) SignTransaction( + tx *SwapTransaction, + nonce uint64, + privateKey []byte, +) (*types.Transaction, error) { + // Create EIP-1559 transaction + ethTx := types.NewTx(&types.DynamicFeeTx{ + ChainID: tb.chainID, + Nonce: nonce, + GasTipCap: tx.MaxPriorityFeePerGas, + GasFeeCap: tx.MaxFeePerGas, + Gas: tx.GasLimit, + To: &tx.To, + Value: tx.Value, + Data: tx.Data, + }) + + // Sign transaction + signer := types.LatestSignerForChainID(tb.chainID) + ecdsaKey, err := crypto.ToECDSA(privateKey) + if err != nil { + return nil, fmt.Errorf("invalid private key: %w", err) + } + + signedTx, err := types.SignTx(ethTx, signer, ecdsaKey) + if err != nil { + return nil, fmt.Errorf("failed to sign transaction: %w", err) + } + + return signedTx, nil +} + +// ValidateTransaction performs pre-execution validation +func (tb *TransactionBuilder) ValidateTransaction(tx *SwapTransaction) error { + // Check gas limit + if tx.GasLimit > tb.config.MaxGasLimit { + return fmt.Errorf("gas limit %d exceeds max %d", tx.GasLimit, tb.config.MaxGasLimit) + } + + // Check slippage + if tx.Slippage > tb.config.MaxSlippageBPS { + return fmt.Errorf("slippage %d bps exceeds max %d bps", tx.Slippage, tb.config.MaxSlippageBPS) + } + + // Check deadline + if tx.Deadline.Before(time.Now()) { + return fmt.Errorf("deadline has passed") + } + + // Check min output + if tx.MinOutput == nil || tx.MinOutput.Sign() <= 0 { + return fmt.Errorf("invalid minimum output") + } + + return nil +} + +// EstimateProfit estimates the actual profit after execution costs +func (tb *TransactionBuilder) EstimateProfit(tx *SwapTransaction) (*big.Int, error) { + // Gas cost = gasLimit * maxFeePerGas + gasCost := new(big.Int).Mul( + big.NewInt(int64(tx.GasLimit)), + tx.MaxFeePerGas, + ) + + // Estimated output (accounting for slippage) + estimatedOutput := tx.MinOutput + + // Profit = output - input - gasCost + profit := new(big.Int).Sub(estimatedOutput, tx.Opportunity.InputAmount) + profit.Sub(profit, gasCost) + + return profit, nil +} diff --git a/pkg/execution/uniswap_v2_encoder.go b/pkg/execution/uniswap_v2_encoder.go new file mode 100644 index 0000000..9e3d456 --- /dev/null +++ b/pkg/execution/uniswap_v2_encoder.go @@ -0,0 +1,206 @@ +package execution + +import ( + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/your-org/mev-bot/pkg/arbitrage" +) + +// UniswapV2 Router address on Arbitrum +var UniswapV2RouterAddress = common.HexToAddress("0x4752ba5dbc23f44d87826276bf6fd6b1c372ad24") + +// UniswapV2Encoder encodes transactions for UniswapV2-style DEXes +type UniswapV2Encoder struct { + routerAddress common.Address +} + +// NewUniswapV2Encoder creates a new UniswapV2 encoder +func NewUniswapV2Encoder() *UniswapV2Encoder { + return &UniswapV2Encoder{ + routerAddress: UniswapV2RouterAddress, + } +} + +// EncodeSwap encodes a single UniswapV2 swap +func (e *UniswapV2Encoder) EncodeSwap( + tokenIn common.Address, + tokenOut common.Address, + amountIn *big.Int, + minAmountOut *big.Int, + poolAddress common.Address, + recipient common.Address, + deadline time.Time, +) (common.Address, []byte, error) { + // swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline) + methodID := crypto.Keccak256([]byte("swapExactTokensForTokens(uint256,uint256,address[],address,uint256)"))[:4] + + // Build path array + path := []common.Address{tokenIn, tokenOut} + + // Encode parameters + data := make([]byte, 0) + data = append(data, methodID...) + + // Offset to dynamic array (5 * 32 bytes) + offset := padLeft(big.NewInt(160).Bytes(), 32) + data = append(data, offset...) + + // amountIn + data = append(data, padLeft(amountIn.Bytes(), 32)...) + + // amountOutMin + data = append(data, padLeft(minAmountOut.Bytes(), 32)...) + + // to (recipient) + data = append(data, padLeft(recipient.Bytes(), 32)...) + + // deadline + deadlineUnix := big.NewInt(deadline.Unix()) + data = append(data, padLeft(deadlineUnix.Bytes(), 32)...) + + // Path array length + data = append(data, padLeft(big.NewInt(int64(len(path))).Bytes(), 32)...) + + // Path elements + for _, addr := range path { + data = append(data, padLeft(addr.Bytes(), 32)...) + } + + return e.routerAddress, data, nil +} + +// EncodeMultiHopSwap encodes a multi-hop UniswapV2 swap +func (e *UniswapV2Encoder) EncodeMultiHopSwap( + opp *arbitrage.Opportunity, + recipient common.Address, + minAmountOut *big.Int, + deadline time.Time, +) (common.Address, []byte, error) { + if len(opp.Path) < 2 { + return common.Address{}, nil, fmt.Errorf("multi-hop requires at least 2 steps") + } + + // Build token path from opportunity path + path := make([]common.Address, len(opp.Path)+1) + path[0] = opp.Path[0].TokenIn + + for i, step := range opp.Path { + path[i+1] = step.TokenOut + } + + // swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline) + methodID := crypto.Keccak256([]byte("swapExactTokensForTokens(uint256,uint256,address[],address,uint256)"))[:4] + + data := make([]byte, 0) + data = append(data, methodID...) + + // Offset to path array (5 * 32 bytes) + offset := padLeft(big.NewInt(160).Bytes(), 32) + data = append(data, offset...) + + // amountIn + data = append(data, padLeft(opp.InputAmount.Bytes(), 32)...) + + // amountOutMin + data = append(data, padLeft(minAmountOut.Bytes(), 32)...) + + // to (recipient) + data = append(data, padLeft(recipient.Bytes(), 32)...) + + // deadline + deadlineUnix := big.NewInt(deadline.Unix()) + data = append(data, padLeft(deadlineUnix.Bytes(), 32)...) + + // Path array length + data = append(data, padLeft(big.NewInt(int64(len(path))).Bytes(), 32)...) + + // Path elements + for _, addr := range path { + data = append(data, padLeft(addr.Bytes(), 32)...) + } + + return e.routerAddress, data, nil +} + +// EncodeSwapWithETH encodes a swap involving ETH +func (e *UniswapV2Encoder) EncodeSwapWithETH( + tokenIn common.Address, + tokenOut common.Address, + amountIn *big.Int, + minAmountOut *big.Int, + recipient common.Address, + deadline time.Time, + isETHInput bool, +) (common.Address, []byte, *big.Int, error) { + var methodSig string + var value *big.Int + + if isETHInput { + // swapExactETHForTokens(uint256 amountOutMin, address[] path, address to, uint256 deadline) + methodSig = "swapExactETHForTokens(uint256,address[],address,uint256)" + value = amountIn + } else { + // swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline) + methodSig = "swapExactTokensForETH(uint256,uint256,address[],address,uint256)" + value = big.NewInt(0) + } + + methodID := crypto.Keccak256([]byte(methodSig))[:4] + + path := []common.Address{tokenIn, tokenOut} + + data := make([]byte, 0) + data = append(data, methodID...) + + if isETHInput { + // Offset to path array (4 * 32 bytes for ETH input) + offset := padLeft(big.NewInt(128).Bytes(), 32) + data = append(data, offset...) + + // amountOutMin + data = append(data, padLeft(minAmountOut.Bytes(), 32)...) + } else { + // Offset to path array (5 * 32 bytes for token input) + offset := padLeft(big.NewInt(160).Bytes(), 32) + data = append(data, offset...) + + // amountIn + data = append(data, padLeft(amountIn.Bytes(), 32)...) + + // amountOutMin + data = append(data, padLeft(minAmountOut.Bytes(), 32)...) + } + + // to (recipient) + data = append(data, padLeft(recipient.Bytes(), 32)...) + + // deadline + deadlineUnix := big.NewInt(deadline.Unix()) + data = append(data, padLeft(deadlineUnix.Bytes(), 32)...) + + // Path array length + data = append(data, padLeft(big.NewInt(int64(len(path))).Bytes(), 32)...) + + // Path elements + for _, addr := range path { + data = append(data, padLeft(addr.Bytes(), 32)...) + } + + return e.routerAddress, data, value, nil +} + +// padLeft pads bytes to the left with zeros to reach the specified length +func padLeft(data []byte, length int) []byte { + if len(data) >= length { + return data + } + + padded := make([]byte, length) + copy(padded[length-len(data):], data) + return padded +} diff --git a/pkg/execution/uniswap_v3_encoder.go b/pkg/execution/uniswap_v3_encoder.go new file mode 100644 index 0000000..1974eaa --- /dev/null +++ b/pkg/execution/uniswap_v3_encoder.go @@ -0,0 +1,271 @@ +package execution + +import ( + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/your-org/mev-bot/pkg/arbitrage" +) + +// UniswapV3 SwapRouter address on Arbitrum +var UniswapV3SwapRouterAddress = common.HexToAddress("0xE592427A0AEce92De3Edee1F18E0157C05861564") + +// UniswapV3Encoder encodes transactions for UniswapV3 +type UniswapV3Encoder struct { + swapRouterAddress common.Address +} + +// NewUniswapV3Encoder creates a new UniswapV3 encoder +func NewUniswapV3Encoder() *UniswapV3Encoder { + return &UniswapV3Encoder{ + swapRouterAddress: UniswapV3SwapRouterAddress, + } +} + +// ExactInputSingleParams represents parameters for exactInputSingle +type ExactInputSingleParams struct { + TokenIn common.Address + TokenOut common.Address + Fee uint32 + Recipient common.Address + Deadline *big.Int + AmountIn *big.Int + AmountOutMinimum *big.Int + SqrtPriceLimitX96 *big.Int +} + +// EncodeSwap encodes a single UniswapV3 swap +func (e *UniswapV3Encoder) EncodeSwap( + tokenIn common.Address, + tokenOut common.Address, + amountIn *big.Int, + minAmountOut *big.Int, + poolAddress common.Address, + fee uint32, + recipient common.Address, + deadline time.Time, +) (common.Address, []byte, error) { + // exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160)) + methodID := crypto.Keccak256([]byte("exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))"))[:4] + + data := make([]byte, 0) + data = append(data, methodID...) + + // Struct offset (always 32 bytes for single struct parameter) + data = append(data, padLeft(big.NewInt(32).Bytes(), 32)...) + + // TokenIn + data = append(data, padLeft(tokenIn.Bytes(), 32)...) + + // TokenOut + data = append(data, padLeft(tokenOut.Bytes(), 32)...) + + // Fee (uint24) + data = append(data, padLeft(big.NewInt(int64(fee)).Bytes(), 32)...) + + // Recipient + data = append(data, padLeft(recipient.Bytes(), 32)...) + + // Deadline + deadlineUnix := big.NewInt(deadline.Unix()) + data = append(data, padLeft(deadlineUnix.Bytes(), 32)...) + + // AmountIn + data = append(data, padLeft(amountIn.Bytes(), 32)...) + + // AmountOutMinimum + data = append(data, padLeft(minAmountOut.Bytes(), 32)...) + + // SqrtPriceLimitX96 (0 = no limit) + data = append(data, padLeft(big.NewInt(0).Bytes(), 32)...) + + return e.swapRouterAddress, data, nil +} + +// EncodeMultiHopSwap encodes a multi-hop UniswapV3 swap using exactInput +func (e *UniswapV3Encoder) EncodeMultiHopSwap( + opp *arbitrage.Opportunity, + recipient common.Address, + minAmountOut *big.Int, + deadline time.Time, +) (common.Address, []byte, error) { + if len(opp.Path) < 2 { + return common.Address{}, nil, fmt.Errorf("multi-hop requires at least 2 steps") + } + + // Build encoded path for UniswapV3 + // Format: tokenIn | fee | tokenOut | fee | tokenOut | ... + encodedPath := e.buildEncodedPath(opp) + + // exactInput((bytes,address,uint256,uint256,uint256)) + methodID := crypto.Keccak256([]byte("exactInput((bytes,address,uint256,uint256,uint256))"))[:4] + + data := make([]byte, 0) + data = append(data, methodID...) + + // Struct offset + data = append(data, padLeft(big.NewInt(32).Bytes(), 32)...) + + // Offset to path bytes (5 * 32 bytes) + data = append(data, padLeft(big.NewInt(160).Bytes(), 32)...) + + // Recipient + data = append(data, padLeft(recipient.Bytes(), 32)...) + + // Deadline + deadlineUnix := big.NewInt(deadline.Unix()) + data = append(data, padLeft(deadlineUnix.Bytes(), 32)...) + + // AmountIn + data = append(data, padLeft(opp.InputAmount.Bytes(), 32)...) + + // AmountOutMinimum + data = append(data, padLeft(minAmountOut.Bytes(), 32)...) + + // Path bytes length + data = append(data, padLeft(big.NewInt(int64(len(encodedPath))).Bytes(), 32)...) + + // Path bytes (padded to 32-byte boundary) + data = append(data, encodedPath...) + + // Pad path to 32-byte boundary + remainder := len(encodedPath) % 32 + if remainder != 0 { + padding := make([]byte, 32-remainder) + data = append(data, padding...) + } + + return e.swapRouterAddress, data, nil +} + +// buildEncodedPath builds the encoded path for UniswapV3 multi-hop swaps +func (e *UniswapV3Encoder) buildEncodedPath(opp *arbitrage.Opportunity) []byte { + // Format: token (20 bytes) | fee (3 bytes) | token (20 bytes) | fee (3 bytes) | ... + // Total: 20 + (23 * (n-1)) bytes for n tokens + + path := make([]byte, 0) + + // First token + path = append(path, opp.Path[0].TokenIn.Bytes()...) + + // For each step, append fee + tokenOut + for _, step := range opp.Path { + // Fee (3 bytes, uint24) + fee := make([]byte, 3) + feeInt := big.NewInt(int64(step.Fee)) + feeBytes := feeInt.Bytes() + copy(fee[3-len(feeBytes):], feeBytes) + path = append(path, fee...) + + // TokenOut (20 bytes) + path = append(path, step.TokenOut.Bytes()...) + } + + return path +} + +// EncodeExactOutput encodes an exactOutputSingle swap (output amount specified) +func (e *UniswapV3Encoder) EncodeExactOutput( + tokenIn common.Address, + tokenOut common.Address, + amountOut *big.Int, + maxAmountIn *big.Int, + fee uint32, + recipient common.Address, + deadline time.Time, +) (common.Address, []byte, error) { + // exactOutputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160)) + methodID := crypto.Keccak256([]byte("exactOutputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))"))[:4] + + data := make([]byte, 0) + data = append(data, methodID...) + + // Struct offset + data = append(data, padLeft(big.NewInt(32).Bytes(), 32)...) + + // TokenIn + data = append(data, padLeft(tokenIn.Bytes(), 32)...) + + // TokenOut + data = append(data, padLeft(tokenOut.Bytes(), 32)...) + + // Fee + data = append(data, padLeft(big.NewInt(int64(fee)).Bytes(), 32)...) + + // Recipient + data = append(data, padLeft(recipient.Bytes(), 32)...) + + // Deadline + deadlineUnix := big.NewInt(deadline.Unix()) + data = append(data, padLeft(deadlineUnix.Bytes(), 32)...) + + // AmountOut + data = append(data, padLeft(amountOut.Bytes(), 32)...) + + // AmountInMaximum + data = append(data, padLeft(maxAmountIn.Bytes(), 32)...) + + // SqrtPriceLimitX96 (0 = no limit) + data = append(data, padLeft(big.NewInt(0).Bytes(), 32)...) + + return e.swapRouterAddress, data, nil +} + +// EncodeMulticall encodes multiple calls into a single transaction +func (e *UniswapV3Encoder) EncodeMulticall( + calls [][]byte, + deadline time.Time, +) (common.Address, []byte, error) { + // multicall(uint256 deadline, bytes[] data) + methodID := crypto.Keccak256([]byte("multicall(uint256,bytes[])"))[:4] + + data := make([]byte, 0) + data = append(data, methodID...) + + // Deadline + deadlineUnix := big.NewInt(deadline.Unix()) + data = append(data, padLeft(deadlineUnix.Bytes(), 32)...) + + // Offset to bytes array (64 bytes: 32 for deadline + 32 for offset) + data = append(data, padLeft(big.NewInt(64).Bytes(), 32)...) + + // Array length + data = append(data, padLeft(big.NewInt(int64(len(calls))).Bytes(), 32)...) + + // Calculate offsets for each call + currentOffset := int64(32 * len(calls)) // Space for all offsets + offsets := make([]int64, len(calls)) + + for i, call := range calls { + offsets[i] = currentOffset + // Each call takes: 32 bytes for length + length (padded to 32) + currentOffset += 32 + int64((len(call)+31)/32*32) + } + + // Write offsets + for _, offset := range offsets { + data = append(data, padLeft(big.NewInt(offset).Bytes(), 32)...) + } + + // Write call data + for _, call := range calls { + // Length + data = append(data, padLeft(big.NewInt(int64(len(call))).Bytes(), 32)...) + + // Data + data = append(data, call...) + + // Padding + remainder := len(call) % 32 + if remainder != 0 { + padding := make([]byte, 32-remainder) + data = append(data, padding...) + } + } + + return e.swapRouterAddress, data, nil +}