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 }