Restructured project for V2 refactor: **Structure Changes:** - Moved all V1 code to orig/ folder (preserved with git mv) - Created docs/planning/ directory - Added orig/README_V1.md explaining V1 preservation **Planning Documents:** - 00_V2_MASTER_PLAN.md: Complete architecture overview - Executive summary of critical V1 issues - High-level component architecture diagrams - 5-phase implementation roadmap - Success metrics and risk mitigation - 07_TASK_BREAKDOWN.md: Atomic task breakdown - 99+ hours of detailed tasks - Every task < 2 hours (atomic) - Clear dependencies and success criteria - Organized by implementation phase **V2 Key Improvements:** - Per-exchange parsers (factory pattern) - Multi-layer strict validation - Multi-index pool cache - Background validation pipeline - Comprehensive observability **Critical Issues Addressed:** - Zero address tokens (strict validation + cache enrichment) - Parsing accuracy (protocol-specific parsers) - No audit trail (background validation channel) - Inefficient lookups (multi-index cache) - Stats disconnection (event-driven metrics) Next Steps: 1. Review planning documents 2. Begin Phase 1: Foundation (P1-001 through P1-010) 3. Implement parsers in Phase 2 4. Build cache system in Phase 3 5. Add validation pipeline in Phase 4 6. Migrate and test in Phase 5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1464 lines
45 KiB
Go
1464 lines
45 KiB
Go
package arbitrage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
stdmath "math"
|
|
"math/big"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ethereum/go-ethereum"
|
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
|
"github.com/ethereum/go-ethereum/accounts/abi/bind"
|
|
"github.com/ethereum/go-ethereum/common"
|
|
"github.com/ethereum/go-ethereum/core/types"
|
|
"github.com/ethereum/go-ethereum/ethclient"
|
|
|
|
"github.com/fraktal/mev-beta/bindings/contracts"
|
|
"github.com/fraktal/mev-beta/bindings/flashswap"
|
|
"github.com/fraktal/mev-beta/internal/logger"
|
|
"github.com/fraktal/mev-beta/pkg/arbitrum"
|
|
"github.com/fraktal/mev-beta/pkg/math"
|
|
"github.com/fraktal/mev-beta/pkg/security"
|
|
pkgtypes "github.com/fraktal/mev-beta/pkg/types"
|
|
)
|
|
|
|
// FlashSwapExecutor executes arbitrage using flash swaps for capital efficiency
|
|
type FlashSwapExecutor struct {
|
|
client *ethclient.Client
|
|
logger *logger.Logger
|
|
keyManager *security.KeyManager
|
|
gasEstimator *arbitrum.L2GasEstimator
|
|
decimalConverter *math.DecimalConverter
|
|
|
|
// Contract addresses
|
|
flashSwapContract common.Address
|
|
arbitrageContract common.Address
|
|
|
|
// Contract bindings
|
|
flashSwapBinding *flashswap.BaseFlashSwapper
|
|
arbitrageBinding arbitrageLogParser
|
|
|
|
// Configuration
|
|
config ExecutionConfig
|
|
|
|
// State tracking
|
|
pendingExecutions map[common.Hash]*ExecutionState
|
|
executionHistory []*ExecutionResult
|
|
totalProfit *math.UniversalDecimal
|
|
totalGasCost *math.UniversalDecimal
|
|
|
|
// Token metadata helpers
|
|
tokenRegistry map[common.Address]tokenDescriptor
|
|
ethReferenceToken common.Address
|
|
}
|
|
|
|
// ExecutionConfig configures the flash swap executor
|
|
type ExecutionConfig struct {
|
|
// Risk management
|
|
MaxSlippage *math.UniversalDecimal
|
|
MinProfitThreshold *math.UniversalDecimal
|
|
MaxPositionSize *math.UniversalDecimal
|
|
MaxDailyVolume *math.UniversalDecimal
|
|
|
|
// Gas settings
|
|
GasLimitMultiplier float64
|
|
MaxGasPrice *math.UniversalDecimal
|
|
PriorityFeeStrategy string // "conservative", "aggressive", "competitive"
|
|
|
|
// Execution settings
|
|
ExecutionTimeout time.Duration
|
|
ConfirmationBlocks uint64
|
|
RetryAttempts int
|
|
RetryDelay time.Duration
|
|
|
|
// MEV protection
|
|
EnableMEVProtection bool
|
|
PrivateMempool bool
|
|
FlashbotsRelay string
|
|
|
|
// Monitoring
|
|
EnableDetailedLogs bool
|
|
TrackPerformance bool
|
|
}
|
|
|
|
// ExecutionState tracks the state of an ongoing execution
|
|
type ExecutionState struct {
|
|
Opportunity *pkgtypes.ArbitrageOpportunity
|
|
TransactionHash common.Hash
|
|
Status ExecutionStatus
|
|
StartTime time.Time
|
|
SubmissionTime time.Time
|
|
ConfirmationTime time.Time
|
|
GasUsed uint64
|
|
EffectiveGasPrice *big.Int
|
|
ActualProfit *math.UniversalDecimal
|
|
Error error
|
|
}
|
|
|
|
// ExecutionStatus represents the current status of an execution
|
|
type ExecutionStatus string
|
|
|
|
const (
|
|
StatusPending ExecutionStatus = "pending"
|
|
StatusSubmitted ExecutionStatus = "submitted"
|
|
StatusConfirmed ExecutionStatus = "confirmed"
|
|
StatusFailed ExecutionStatus = "failed"
|
|
StatusReverted ExecutionStatus = "reverted"
|
|
)
|
|
|
|
// FlashSwapCalldata represents the data needed for a flash swap execution
|
|
type FlashSwapCalldata struct {
|
|
InitiatorPool common.Address
|
|
TokenPath []common.Address
|
|
Pools []common.Address
|
|
AmountIn *big.Int
|
|
MinAmountOut *big.Int
|
|
Recipient common.Address
|
|
Data []byte
|
|
}
|
|
|
|
type arbitrageLogParser interface {
|
|
ParseArbitrageExecuted(types.Log) (*contracts.ArbitrageExecutorArbitrageExecuted, error)
|
|
}
|
|
|
|
type tokenDescriptor struct {
|
|
Symbol string
|
|
Decimals uint8
|
|
PriceUSD *big.Rat
|
|
}
|
|
|
|
var (
|
|
revertErrorSelector = []byte{0x08, 0xc3, 0x79, 0xa0} // keccak256("Error(string)")[:4]
|
|
revertPanicSelector = []byte{0x4e, 0x48, 0x7b, 0x71} // keccak256("Panic(uint256)")[:4]
|
|
)
|
|
|
|
// NewFlashSwapExecutor creates a new flash swap executor
|
|
func NewFlashSwapExecutor(
|
|
client *ethclient.Client,
|
|
logger *logger.Logger,
|
|
keyManager *security.KeyManager,
|
|
gasEstimator *arbitrum.L2GasEstimator,
|
|
flashSwapContract,
|
|
arbitrageContract common.Address,
|
|
config ExecutionConfig,
|
|
) *FlashSwapExecutor {
|
|
|
|
executor := &FlashSwapExecutor{
|
|
client: client,
|
|
logger: logger,
|
|
keyManager: keyManager,
|
|
gasEstimator: gasEstimator,
|
|
decimalConverter: math.NewDecimalConverter(),
|
|
flashSwapContract: flashSwapContract,
|
|
arbitrageContract: arbitrageContract,
|
|
config: config,
|
|
pendingExecutions: make(map[common.Hash]*ExecutionState),
|
|
executionHistory: make([]*ExecutionResult, 0),
|
|
tokenRegistry: defaultTokenRegistry(),
|
|
ethReferenceToken: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
|
|
}
|
|
|
|
if client != nil && flashSwapContract != (common.Address{}) {
|
|
if binding, err := flashswap.NewBaseFlashSwapper(flashSwapContract, client); err != nil {
|
|
if logger != nil {
|
|
logger.Warn(fmt.Sprintf("Failed to initialise flash swap contract binding: %v", err))
|
|
}
|
|
} else {
|
|
executor.flashSwapBinding = binding
|
|
}
|
|
}
|
|
|
|
if client != nil && arbitrageContract != (common.Address{}) {
|
|
if binding, err := contracts.NewArbitrageExecutor(arbitrageContract, client); err != nil {
|
|
if logger != nil {
|
|
logger.Warn(fmt.Sprintf("Failed to initialise arbitrage contract binding: %v", err))
|
|
}
|
|
} else {
|
|
executor.arbitrageBinding = binding
|
|
}
|
|
}
|
|
|
|
// Initialize counters
|
|
executor.totalProfit, _ = executor.decimalConverter.FromString("0", 18, "ETH")
|
|
executor.totalGasCost, _ = executor.decimalConverter.FromString("0", 18, "ETH")
|
|
|
|
// Set default configuration
|
|
executor.setDefaultConfig()
|
|
|
|
return executor
|
|
}
|
|
|
|
// setDefaultConfig sets default configuration values
|
|
func (executor *FlashSwapExecutor) setDefaultConfig() {
|
|
if executor.config.MaxSlippage == nil {
|
|
executor.config.MaxSlippage, _ = executor.decimalConverter.FromString("1", 4, "PERCENT") // 1%
|
|
}
|
|
|
|
if executor.config.MinProfitThreshold == nil {
|
|
executor.config.MinProfitThreshold, _ = executor.decimalConverter.FromString("0.01", 18, "ETH")
|
|
}
|
|
|
|
if executor.config.MaxPositionSize == nil {
|
|
executor.config.MaxPositionSize, _ = executor.decimalConverter.FromString("10", 18, "ETH")
|
|
}
|
|
|
|
if executor.config.GasLimitMultiplier == 0 {
|
|
executor.config.GasLimitMultiplier = 1.2 // 20% buffer
|
|
}
|
|
|
|
if executor.config.ExecutionTimeout == 0 {
|
|
executor.config.ExecutionTimeout = 30 * time.Second
|
|
}
|
|
|
|
if executor.config.ConfirmationBlocks == 0 {
|
|
executor.config.ConfirmationBlocks = 1 // Arbitrum has fast finality
|
|
}
|
|
|
|
if executor.config.RetryAttempts == 0 {
|
|
executor.config.RetryAttempts = 3
|
|
}
|
|
|
|
if executor.config.RetryDelay == 0 {
|
|
executor.config.RetryDelay = 2 * time.Second
|
|
}
|
|
|
|
if executor.config.PriorityFeeStrategy == "" {
|
|
executor.config.PriorityFeeStrategy = "competitive"
|
|
}
|
|
}
|
|
|
|
// ExecuteArbitrage executes an arbitrage opportunity using flash swaps
|
|
func (executor *FlashSwapExecutor) ExecuteArbitrage(ctx context.Context, opportunity *pkgtypes.ArbitrageOpportunity) (*ExecutionResult, error) {
|
|
// Validate opportunity before execution
|
|
if err := executor.validateOpportunity(opportunity); err != nil {
|
|
return nil, fmt.Errorf("opportunity validation failed: %w", err)
|
|
}
|
|
|
|
profitEstimate, err := executor.profitEstimateWei(opportunity)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to determine profit estimate: %w", err)
|
|
}
|
|
|
|
profitDisplay := ethAmountString(executor.decimalConverter, nil, profitEstimate)
|
|
executor.logger.Info(fmt.Sprintf("🚀 Executing arbitrage opportunity: %s ETH profit expected",
|
|
profitDisplay))
|
|
|
|
// Create execution state
|
|
executionState := &ExecutionState{
|
|
Opportunity: opportunity,
|
|
Status: StatusPending,
|
|
StartTime: time.Now(),
|
|
}
|
|
|
|
// Prepare flash swap transaction
|
|
flashSwapData, err := executor.prepareFlashSwap(opportunity)
|
|
if err != nil {
|
|
result := executor.createFailedResult(executionState, fmt.Errorf("failed to prepare flash swap: %w", err))
|
|
return result, nil
|
|
}
|
|
|
|
// Get transaction options with dynamic gas pricing
|
|
transactOpts, err := executor.getTransactionOptions(ctx, flashSwapData)
|
|
if err != nil {
|
|
return executor.createFailedResult(executionState, fmt.Errorf("failed to get transaction options: %w", err)), nil
|
|
}
|
|
|
|
// Execute with retry logic
|
|
var result *ExecutionResult
|
|
for attempt := 0; attempt <= executor.config.RetryAttempts; attempt++ {
|
|
if attempt > 0 {
|
|
executor.logger.Info(fmt.Sprintf("Retrying execution attempt %d/%d", attempt, executor.config.RetryAttempts))
|
|
time.Sleep(executor.config.RetryDelay)
|
|
}
|
|
|
|
result = executor.executeWithTimeout(ctx, executionState, flashSwapData, transactOpts)
|
|
|
|
// If successful or non-retryable error, break
|
|
errorMsg := ""
|
|
if result.Error != nil {
|
|
errorMsg = result.Error.Error()
|
|
}
|
|
if result.Success || !executor.isRetryableError(errorMsg) {
|
|
break
|
|
}
|
|
|
|
// Update gas price for retry
|
|
if attempt < executor.config.RetryAttempts {
|
|
transactOpts, err = executor.updateGasPriceForRetry(ctx, transactOpts, attempt)
|
|
if err != nil {
|
|
executor.logger.Warn(fmt.Sprintf("Failed to update gas price for retry: %v", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update statistics
|
|
executor.updateExecutionStats(result)
|
|
|
|
status := "Unknown"
|
|
if result.Success {
|
|
status = "Success"
|
|
} else if result.Error != nil {
|
|
status = "Failed"
|
|
} else {
|
|
status = "Incomplete"
|
|
}
|
|
|
|
executor.logger.Info(fmt.Sprintf("✅ Arbitrage execution completed: %s", status))
|
|
if result.Success && result.ProfitRealized != nil {
|
|
// Note: opportunity.NetProfit is not directly accessible through ExecutionResult structure
|
|
// So we just log that execution was successful with actual profit
|
|
profitDisplay := ethAmountString(executor.decimalConverter, nil, result.ProfitRealized)
|
|
executor.logger.Info(fmt.Sprintf("💰 Actual profit: %s ETH",
|
|
profitDisplay))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// validateOpportunity validates an opportunity before execution
|
|
func (executor *FlashSwapExecutor) validateOpportunity(opportunity *pkgtypes.ArbitrageOpportunity) error {
|
|
if opportunity == nil {
|
|
return fmt.Errorf("opportunity cannot be nil")
|
|
}
|
|
|
|
if opportunity.AmountIn == nil || opportunity.AmountIn.Sign() <= 0 {
|
|
return fmt.Errorf("invalid amount in for opportunity")
|
|
}
|
|
|
|
netProfit, err := executor.profitEstimateWei(opportunity)
|
|
if err != nil {
|
|
return fmt.Errorf("profit estimate invalid: %w", err)
|
|
}
|
|
|
|
// Check minimum profit threshold
|
|
// Set to 0.001 ETH to ensure profitability after gas costs
|
|
// Arbitrum gas cost: ~100k-200k gas @ 0.1-0.2 gwei = ~0.00002-0.00004 ETH
|
|
// 0.001 ETH provides ~25-50x gas cost safety margin
|
|
minProfitWei := big.NewInt(1000000000000000) // 0.001 ETH in wei
|
|
if netProfit.Cmp(minProfitWei) < 0 {
|
|
return fmt.Errorf("profit %s below minimum threshold %s",
|
|
netProfit.String(),
|
|
minProfitWei.String())
|
|
}
|
|
|
|
// Check maximum position size
|
|
maxPositionWei := big.NewInt(1000000000000000000) // 1 ETH in wei
|
|
if opportunity.AmountIn.Cmp(maxPositionWei) > 0 {
|
|
return fmt.Errorf("position size %s exceeds maximum %s",
|
|
opportunity.AmountIn.String(),
|
|
maxPositionWei.String())
|
|
}
|
|
|
|
// Check price impact
|
|
maxPriceImpact := 5.0 // 5% max
|
|
if opportunity.PriceImpact > maxPriceImpact {
|
|
return fmt.Errorf("price impact %.2f%% too high",
|
|
opportunity.PriceImpact)
|
|
}
|
|
|
|
// Check confidence level
|
|
if opportunity.Confidence < 0.7 {
|
|
return fmt.Errorf("confidence level %.1f%% too low", opportunity.Confidence*100)
|
|
}
|
|
|
|
// Check execution path
|
|
if len(opportunity.Path) < 2 {
|
|
return fmt.Errorf("empty execution path")
|
|
}
|
|
|
|
// Basic validation for path
|
|
if len(opportunity.Path) < 2 {
|
|
return fmt.Errorf("path must have at least 2 tokens")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// prepareFlashSwap prepares the flash swap transaction data
|
|
func (executor *FlashSwapExecutor) prepareFlashSwap(opportunity *pkgtypes.ArbitrageOpportunity) (*FlashSwapCalldata, error) {
|
|
if opportunity == nil {
|
|
return nil, fmt.Errorf("opportunity cannot be nil")
|
|
}
|
|
|
|
if len(opportunity.Path) < 2 {
|
|
return nil, fmt.Errorf("path must have at least 2 tokens")
|
|
}
|
|
|
|
if opportunity.AmountIn == nil || opportunity.AmountIn.Sign() <= 0 {
|
|
return nil, fmt.Errorf("opportunity amount in must be positive")
|
|
}
|
|
|
|
// Convert path strings to token addresses
|
|
tokenPath := make([]common.Address, len(opportunity.Path))
|
|
for i, tokenAddr := range opportunity.Path {
|
|
tokenPath[i] = common.HexToAddress(tokenAddr)
|
|
}
|
|
|
|
// Use pool addresses from opportunity if available
|
|
poolAddresses := make([]common.Address, len(opportunity.Pools))
|
|
for i, poolAddr := range opportunity.Pools {
|
|
poolAddresses[i] = common.HexToAddress(poolAddr)
|
|
}
|
|
|
|
if len(poolAddresses) == 0 {
|
|
return nil, fmt.Errorf("opportunity missing pool data")
|
|
}
|
|
|
|
profitEstimate, err := executor.profitEstimateWei(opportunity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
expectedOutput := new(big.Int).Add(opportunity.AmountIn, profitEstimate)
|
|
if expectedOutput.Sign() <= 0 {
|
|
return nil, fmt.Errorf("expected output amount must be positive")
|
|
}
|
|
|
|
slippageFraction := executor.resolveSlippage(opportunity)
|
|
if slippageFraction < 0 {
|
|
slippageFraction = 0
|
|
}
|
|
if slippageFraction >= 1 {
|
|
slippageFraction = 0.99
|
|
}
|
|
|
|
minAmountOut := new(big.Int).Set(expectedOutput)
|
|
if slippageFraction > 0 {
|
|
minOutputFloat := new(big.Float).Mul(new(big.Float).SetInt(expectedOutput), big.NewFloat(1-slippageFraction))
|
|
minAmountOut = new(big.Int)
|
|
minOutputFloat.Int(minAmountOut)
|
|
}
|
|
|
|
if minAmountOut.Sign() <= 0 {
|
|
return nil, fmt.Errorf("minimum amount out must be positive")
|
|
}
|
|
|
|
calldata := &FlashSwapCalldata{
|
|
InitiatorPool: poolAddresses[0], // First pool initiates the flash swap
|
|
TokenPath: tokenPath,
|
|
Pools: poolAddresses,
|
|
AmountIn: new(big.Int).Set(opportunity.AmountIn),
|
|
MinAmountOut: minAmountOut,
|
|
Recipient: executor.arbitrageContract, // Arbitrage contract receives callback
|
|
Data: executor.encodeArbitrageData(tokenPath, poolAddresses, nil, minAmountOut),
|
|
}
|
|
|
|
return calldata, nil
|
|
}
|
|
|
|
// encodeArbitrageData encodes the arbitrage execution data
|
|
func (executor *FlashSwapExecutor) encodeArbitrageData(tokenPath, poolPath []common.Address, fees []*big.Int, minAmountOut *big.Int) []byte {
|
|
payload, err := encodeFlashSwapCallback(tokenPath, poolPath, fees, minAmountOut)
|
|
if err != nil {
|
|
if executor.logger != nil {
|
|
executor.logger.Warn(fmt.Sprintf("Failed to encode flash swap callback data: %v", err))
|
|
}
|
|
// Provide a structured fallback payload for debugging purposes
|
|
fallback := []string{"arbitrage"}
|
|
for _, token := range tokenPath {
|
|
fallback = append(fallback, token.Hex())
|
|
}
|
|
return []byte(strings.Join(fallback, ":"))
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) profitEstimateWei(opportunity *pkgtypes.ArbitrageOpportunity) (*big.Int, error) {
|
|
if opportunity == nil {
|
|
return nil, fmt.Errorf("opportunity cannot be nil")
|
|
}
|
|
|
|
candidates := []*big.Int{
|
|
opportunity.NetProfit,
|
|
opportunity.Profit,
|
|
opportunity.EstimatedProfit,
|
|
}
|
|
|
|
for _, candidate := range candidates {
|
|
if candidate != nil && candidate.Sign() > 0 {
|
|
return new(big.Int).Set(candidate), nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("opportunity profit estimate unavailable or non-positive")
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) resolveSlippage(opportunity *pkgtypes.ArbitrageOpportunity) float64 {
|
|
if opportunity != nil && opportunity.MaxSlippage > 0 {
|
|
if opportunity.MaxSlippage > 1 {
|
|
return opportunity.MaxSlippage / 100.0
|
|
}
|
|
return opportunity.MaxSlippage
|
|
}
|
|
|
|
if executor.config.MaxSlippage != nil && executor.config.MaxSlippage.Value != nil {
|
|
numerator := new(big.Float).SetInt(executor.config.MaxSlippage.Value)
|
|
denominator := big.NewFloat(stdmath.Pow10(int(executor.config.MaxSlippage.Decimals)))
|
|
if denominator.Sign() != 0 {
|
|
percent, _ := new(big.Float).Quo(numerator, denominator).Float64()
|
|
if percent > 0 {
|
|
return percent / 100.0
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0.01 // Default to 1% if nothing provided
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) gasEstimateWei(opportunity *pkgtypes.ArbitrageOpportunity) *big.Int {
|
|
if opportunity == nil {
|
|
return nil
|
|
}
|
|
if opportunity.GasEstimate != nil {
|
|
return new(big.Int).Set(opportunity.GasEstimate)
|
|
}
|
|
if opportunity.GasCost != nil {
|
|
return new(big.Int).Set(opportunity.GasCost)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getTransactionOptions prepares transaction options with dynamic gas pricing
|
|
func (executor *FlashSwapExecutor) getTransactionOptions(ctx context.Context, flashSwapData *FlashSwapCalldata) (*bind.TransactOpts, error) {
|
|
if executor.client == nil {
|
|
return nil, fmt.Errorf("ethereum client not configured")
|
|
}
|
|
if executor.keyManager == nil {
|
|
return nil, fmt.Errorf("key manager not configured")
|
|
}
|
|
if flashSwapData == nil {
|
|
return nil, fmt.Errorf("flash swap data cannot be nil")
|
|
}
|
|
|
|
privateKey, err := executor.keyManager.GetActivePrivateKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get private key: %w", err)
|
|
}
|
|
|
|
chainID, err := executor.client.ChainID(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get chain ID: %w", err)
|
|
}
|
|
|
|
transactOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, chainID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create transactor: %w", err)
|
|
}
|
|
|
|
nonce, err := executor.client.PendingNonceAt(ctx, transactOpts.From)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch account nonce: %w", err)
|
|
}
|
|
transactOpts.Nonce = new(big.Int).SetUint64(nonce)
|
|
|
|
params, err := executor.buildFlashSwapParams(flashSwapData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
callData, err := executor.encodeFlashSwapCall(flashSwapData.Pools[0], params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode flash swap calldata: %w", err)
|
|
}
|
|
|
|
gasLimit := uint64(800000) // Sensible default for complex flash swaps
|
|
var candidateFeeCap *big.Int
|
|
var candidateTip *big.Int
|
|
|
|
if executor.flashSwapContract == (common.Address{}) {
|
|
executor.logger.Warn("Flash swap contract address not configured; using default gas limit")
|
|
} else {
|
|
callMsg := ethereum.CallMsg{
|
|
From: transactOpts.From,
|
|
To: &executor.flashSwapContract,
|
|
Data: callData,
|
|
Value: func() *big.Int {
|
|
if transactOpts.Value == nil {
|
|
return big.NewInt(0)
|
|
}
|
|
return transactOpts.Value
|
|
}(),
|
|
}
|
|
|
|
if estimatedGas, gasErr := executor.client.EstimateGas(ctx, callMsg); gasErr == nil && estimatedGas > 0 {
|
|
gasLimit = estimatedGas
|
|
} else {
|
|
if gasErr != nil {
|
|
executor.logger.Warn(fmt.Sprintf("Gas estimation via RPC failed: %v", gasErr))
|
|
}
|
|
if executor.gasEstimator != nil {
|
|
dummyTx := types.NewTx(&types.DynamicFeeTx{
|
|
ChainID: chainID,
|
|
Nonce: nonce,
|
|
To: &executor.flashSwapContract,
|
|
Value: callMsg.Value,
|
|
Data: callData,
|
|
})
|
|
|
|
if estimate, estErr := executor.gasEstimator.EstimateL2Gas(ctx, dummyTx); estErr == nil {
|
|
if estimate.GasLimit > 0 {
|
|
gasLimit = estimate.GasLimit
|
|
}
|
|
candidateFeeCap = estimate.MaxFeePerGas
|
|
candidateTip = estimate.MaxPriorityFee
|
|
} else {
|
|
executor.logger.Warn(fmt.Sprintf("Arbitrum gas estimator fallback failed: %v", estErr))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
multiplier := executor.config.GasLimitMultiplier
|
|
if multiplier <= 0 {
|
|
multiplier = 1.2
|
|
}
|
|
adjustedGasLimit := gasLimit
|
|
if multiplier != 1.0 {
|
|
adjusted := uint64(stdmath.Ceil(float64(gasLimit) * multiplier))
|
|
if adjusted < gasLimit {
|
|
adjusted = gasLimit
|
|
}
|
|
if adjusted == 0 {
|
|
adjusted = gasLimit
|
|
}
|
|
adjustedGasLimit = adjusted
|
|
}
|
|
if adjustedGasLimit == 0 {
|
|
adjustedGasLimit = gasLimit
|
|
}
|
|
transactOpts.GasLimit = adjustedGasLimit
|
|
|
|
if candidateTip == nil {
|
|
if suggestedTip, tipErr := executor.client.SuggestGasTipCap(ctx); tipErr == nil {
|
|
candidateTip = suggestedTip
|
|
} else {
|
|
candidateTip = big.NewInt(100000000) // 0.1 gwei fallback
|
|
executor.logger.Debug(fmt.Sprintf("Using fallback priority fee: %s", candidateTip.String()))
|
|
}
|
|
}
|
|
|
|
if candidateFeeCap == nil {
|
|
if header, headerErr := executor.client.HeaderByNumber(ctx, nil); headerErr == nil && header != nil && header.BaseFee != nil {
|
|
candidateFeeCap = new(big.Int).Add(new(big.Int).Mul(header.BaseFee, big.NewInt(2)), candidateTip)
|
|
} else {
|
|
defaultBase := big.NewInt(1000000000) // 1 gwei
|
|
candidateFeeCap = new(big.Int).Add(defaultBase, candidateTip)
|
|
}
|
|
}
|
|
|
|
transactOpts.GasTipCap = new(big.Int).Set(candidateTip)
|
|
transactOpts.GasFeeCap = new(big.Int).Set(candidateFeeCap)
|
|
|
|
baseStr := fmt.Sprintf("%d", gasLimit)
|
|
maxFeeStr := "<nil>"
|
|
tipStr := "<nil>"
|
|
if transactOpts.GasFeeCap != nil {
|
|
maxFeeStr = transactOpts.GasFeeCap.String()
|
|
}
|
|
if transactOpts.GasTipCap != nil {
|
|
tipStr = transactOpts.GasTipCap.String()
|
|
}
|
|
executor.logger.Debug(fmt.Sprintf("Gas estimate - Base: %s, Adjusted: %d, MaxFee: %s, Priority: %s",
|
|
baseStr,
|
|
transactOpts.GasLimit,
|
|
maxFeeStr,
|
|
tipStr))
|
|
|
|
// Apply priority fee strategy and enforce configured limits
|
|
executor.applyPriorityFeeStrategy(transactOpts)
|
|
executor.enforceGasBounds(transactOpts)
|
|
|
|
return transactOpts, nil
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) buildFlashSwapParams(flashSwapData *FlashSwapCalldata) (flashswap.FlashSwapParams, error) {
|
|
if flashSwapData == nil {
|
|
return flashswap.FlashSwapParams{}, fmt.Errorf("flash swap data cannot be nil")
|
|
}
|
|
|
|
if len(flashSwapData.TokenPath) < 2 {
|
|
return flashswap.FlashSwapParams{}, fmt.Errorf("token path must include at least two tokens")
|
|
}
|
|
|
|
if len(flashSwapData.Pools) == 0 {
|
|
return flashswap.FlashSwapParams{}, fmt.Errorf("pool path cannot be empty")
|
|
}
|
|
|
|
if flashSwapData.AmountIn == nil || flashSwapData.AmountIn.Sign() <= 0 {
|
|
return flashswap.FlashSwapParams{}, fmt.Errorf("amount in must be positive")
|
|
}
|
|
|
|
if flashSwapData.MinAmountOut == nil || flashSwapData.MinAmountOut.Sign() <= 0 {
|
|
return flashswap.FlashSwapParams{}, fmt.Errorf("minimum amount out must be positive")
|
|
}
|
|
|
|
params := flashswap.FlashSwapParams{
|
|
Token0: flashSwapData.TokenPath[0],
|
|
Token1: flashSwapData.TokenPath[1],
|
|
Amount0: flashSwapData.AmountIn,
|
|
Amount1: big.NewInt(0),
|
|
To: executor.arbitrageContract,
|
|
Data: flashSwapData.Data,
|
|
}
|
|
|
|
return params, nil
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) encodeFlashSwapCall(pool common.Address, params flashswap.FlashSwapParams) ([]byte, error) {
|
|
if pool == (common.Address{}) {
|
|
return nil, fmt.Errorf("pool address cannot be zero")
|
|
}
|
|
|
|
flashSwapABI, err := flashswap.BaseFlashSwapperMetaData.GetAbi()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load BaseFlashSwapper ABI: %w", err)
|
|
}
|
|
|
|
return flashSwapABI.Pack("executeFlashSwap", pool, params)
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) maxGasPriceWei() *big.Int {
|
|
if executor.config.MaxGasPrice == nil || executor.config.MaxGasPrice.Value == nil {
|
|
return nil
|
|
}
|
|
|
|
if executor.decimalConverter == nil {
|
|
return new(big.Int).Set(executor.config.MaxGasPrice.Value)
|
|
}
|
|
|
|
weiDecimal := executor.decimalConverter.ToWei(executor.config.MaxGasPrice)
|
|
if weiDecimal == nil || weiDecimal.Value == nil {
|
|
return new(big.Int).Set(executor.config.MaxGasPrice.Value)
|
|
}
|
|
|
|
return new(big.Int).Set(weiDecimal.Value)
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) enforceGasBounds(transactOpts *bind.TransactOpts) {
|
|
if transactOpts == nil {
|
|
return
|
|
}
|
|
|
|
maxGas := executor.maxGasPriceWei()
|
|
if maxGas == nil || maxGas.Sign() <= 0 {
|
|
return
|
|
}
|
|
|
|
if transactOpts.GasFeeCap != nil && transactOpts.GasFeeCap.Cmp(maxGas) > 0 {
|
|
transactOpts.GasFeeCap = new(big.Int).Set(maxGas)
|
|
executor.logger.Debug(fmt.Sprintf("Clamped gas fee cap to configured maximum %s", maxGas.String()))
|
|
}
|
|
|
|
if transactOpts.GasTipCap != nil && transactOpts.GasTipCap.Cmp(maxGas) > 0 {
|
|
transactOpts.GasTipCap = new(big.Int).Set(maxGas)
|
|
executor.logger.Debug(fmt.Sprintf("Clamped priority fee to configured maximum %s", maxGas.String()))
|
|
}
|
|
|
|
if transactOpts.GasFeeCap != nil && transactOpts.GasTipCap != nil && transactOpts.GasFeeCap.Cmp(transactOpts.GasTipCap) < 0 {
|
|
transactOpts.GasFeeCap = new(big.Int).Set(transactOpts.GasTipCap)
|
|
}
|
|
}
|
|
|
|
// applyPriorityFeeStrategy adjusts gas pricing based on strategy
|
|
func (executor *FlashSwapExecutor) applyPriorityFeeStrategy(transactOpts *bind.TransactOpts) {
|
|
switch executor.config.PriorityFeeStrategy {
|
|
case "aggressive":
|
|
// Increase priority fee by 50%
|
|
if transactOpts.GasTipCap != nil {
|
|
newTip := new(big.Int).Mul(transactOpts.GasTipCap, big.NewInt(150))
|
|
transactOpts.GasTipCap = new(big.Int).Div(newTip, big.NewInt(100))
|
|
}
|
|
case "competitive":
|
|
// Increase priority fee by 25%
|
|
if transactOpts.GasTipCap != nil {
|
|
newTip := new(big.Int).Mul(transactOpts.GasTipCap, big.NewInt(125))
|
|
transactOpts.GasTipCap = new(big.Int).Div(newTip, big.NewInt(100))
|
|
}
|
|
case "conservative":
|
|
// Use default priority fee (no change)
|
|
}
|
|
}
|
|
|
|
// executeWithTimeout executes the flash swap with timeout protection
|
|
func (executor *FlashSwapExecutor) executeWithTimeout(
|
|
ctx context.Context,
|
|
executionState *ExecutionState,
|
|
flashSwapData *FlashSwapCalldata,
|
|
transactOpts *bind.TransactOpts,
|
|
) *ExecutionResult {
|
|
|
|
// Create timeout context
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, executor.config.ExecutionTimeout)
|
|
defer cancel()
|
|
|
|
// Submit transaction
|
|
tx, err := executor.submitTransaction(timeoutCtx, flashSwapData, transactOpts)
|
|
if err != nil {
|
|
return executor.createFailedResult(executionState, fmt.Errorf("transaction submission failed: %w", err))
|
|
}
|
|
|
|
executionState.TransactionHash = tx.Hash()
|
|
executionState.Status = StatusSubmitted
|
|
executionState.SubmissionTime = time.Now()
|
|
executor.pendingExecutions[tx.Hash()] = executionState
|
|
|
|
executor.logger.Info(fmt.Sprintf("📤 Transaction submitted: %s", tx.Hash().Hex()))
|
|
|
|
// Wait for confirmation
|
|
receipt, err := executor.waitForConfirmation(timeoutCtx, tx.Hash())
|
|
if err != nil {
|
|
return executor.createFailedResult(executionState, fmt.Errorf("confirmation failed: %w", err))
|
|
}
|
|
|
|
executionState.ConfirmationTime = time.Now()
|
|
executionState.GasUsed = receipt.GasUsed
|
|
executionState.EffectiveGasPrice = receipt.EffectiveGasPrice
|
|
|
|
// Check transaction status
|
|
if receipt.Status == types.ReceiptStatusFailed {
|
|
executionState.Status = StatusReverted
|
|
revertReason := executor.fetchRevertReason(timeoutCtx, tx.Hash(), receipt)
|
|
if revertReason != "" {
|
|
return executor.createFailedResult(executionState, fmt.Errorf("transaction reverted: %s", revertReason))
|
|
}
|
|
return executor.createFailedResult(executionState, fmt.Errorf("transaction reverted"))
|
|
}
|
|
|
|
executionState.Status = StatusConfirmed
|
|
|
|
// Calculate actual results
|
|
actualProfit, err := executor.calculateActualProfit(receipt, executionState.Opportunity)
|
|
if err != nil {
|
|
executor.logger.Warn(fmt.Sprintf("Failed to calculate actual profit: %v", err))
|
|
if estimate, estimateErr := executor.profitEstimateWei(executionState.Opportunity); estimateErr == nil {
|
|
actualProfit = universalFromWei(executor.decimalConverter, estimate, "ETH")
|
|
} else {
|
|
actualProfit, _ = math.NewUniversalDecimal(big.NewInt(0), 18, "ETH")
|
|
}
|
|
}
|
|
|
|
executionState.ActualProfit = actualProfit
|
|
|
|
// Create successful result
|
|
return executor.createSuccessfulResult(executionState, receipt)
|
|
}
|
|
|
|
// submitTransaction submits the flash swap transaction
|
|
func (executor *FlashSwapExecutor) submitTransaction(
|
|
ctx context.Context,
|
|
flashSwapData *FlashSwapCalldata,
|
|
transactOpts *bind.TransactOpts,
|
|
) (*types.Transaction, error) {
|
|
if executor.flashSwapBinding == nil {
|
|
return nil, fmt.Errorf("flash swap contract binding not initialised")
|
|
}
|
|
|
|
if executor.arbitrageContract == (common.Address{}) {
|
|
return nil, fmt.Errorf("arbitrage contract address not configured")
|
|
}
|
|
|
|
params, err := executor.buildFlashSwapParams(flashSwapData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
executor.logger.Debug("Submitting flash swap transaction...")
|
|
executor.logger.Debug(fmt.Sprintf(" Initiator Pool: %s", flashSwapData.InitiatorPool.Hex()))
|
|
executor.logger.Debug(fmt.Sprintf(" Amount In: %s", flashSwapData.AmountIn.String()))
|
|
executor.logger.Debug(fmt.Sprintf(" Min Amount Out: %s", flashSwapData.MinAmountOut.String()))
|
|
executor.logger.Debug(fmt.Sprintf(" Token Path: %d tokens", len(flashSwapData.TokenPath)))
|
|
executor.logger.Debug(fmt.Sprintf(" Pool Path: %d pools", len(flashSwapData.Pools)))
|
|
|
|
opts := *transactOpts
|
|
opts.Context = ctx
|
|
|
|
tx, err := executor.flashSwapBinding.ExecuteFlashSwap(&opts, flashSwapData.Pools[0], params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return tx, nil
|
|
}
|
|
|
|
// waitForConfirmation waits for transaction confirmation
|
|
func (executor *FlashSwapExecutor) waitForConfirmation(ctx context.Context, txHash common.Hash) (*types.Receipt, error) {
|
|
executor.logger.Debug(fmt.Sprintf("Waiting for confirmation of transaction: %s", txHash.Hex()))
|
|
|
|
ticker := time.NewTicker(2 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("timeout waiting for confirmation: %w", ctx.Err())
|
|
case <-ticker.C:
|
|
receipt, err := executor.client.TransactionReceipt(ctx, txHash)
|
|
if err != nil {
|
|
if errors.Is(err, ethereum.NotFound) {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err)
|
|
}
|
|
|
|
if receipt == nil {
|
|
continue
|
|
}
|
|
|
|
if receipt.BlockNumber == nil || executor.config.ConfirmationBlocks <= 1 {
|
|
return receipt, nil
|
|
}
|
|
|
|
targetBlock := new(big.Int).Add(receipt.BlockNumber, big.NewInt(int64(executor.config.ConfirmationBlocks-1)))
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("timeout waiting for confirmations: %w", ctx.Err())
|
|
case <-ticker.C:
|
|
header, headerErr := executor.client.HeaderByNumber(ctx, nil)
|
|
if headerErr != nil {
|
|
if errors.Is(headerErr, ethereum.NotFound) {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("failed to fetch latest block header: %w", headerErr)
|
|
}
|
|
|
|
if header != nil && header.Number.Cmp(targetBlock) >= 0 {
|
|
return receipt, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) fetchRevertReason(ctx context.Context, txHash common.Hash, receipt *types.Receipt) string {
|
|
if executor.client == nil || receipt == nil {
|
|
return ""
|
|
}
|
|
|
|
callCtx := ctx
|
|
if callCtx == nil || callCtx.Err() != nil {
|
|
var cancel context.CancelFunc
|
|
callCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
}
|
|
|
|
tx, _, err := executor.client.TransactionByHash(callCtx, txHash)
|
|
if err != nil || tx == nil {
|
|
if err != nil && executor.logger != nil {
|
|
executor.logger.Debug(fmt.Sprintf("Failed to fetch transaction for revert reason: %v", err))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
if tx.To() == nil {
|
|
return ""
|
|
}
|
|
|
|
msg := ethereum.CallMsg{
|
|
To: tx.To(),
|
|
Data: tx.Data(),
|
|
Gas: tx.Gas(),
|
|
Value: tx.Value(),
|
|
}
|
|
|
|
switch tx.Type() {
|
|
case types.DynamicFeeTxType:
|
|
msg.GasFeeCap = tx.GasFeeCap()
|
|
msg.GasTipCap = tx.GasTipCap()
|
|
default:
|
|
msg.GasPrice = tx.GasPrice()
|
|
}
|
|
|
|
revertData, callErr := executor.client.CallContract(callCtx, msg, receipt.BlockNumber)
|
|
if callErr != nil || len(revertData) == 0 {
|
|
if callErr != nil && executor.logger != nil {
|
|
executor.logger.Debug(fmt.Sprintf("Failed to retrieve revert data: %v", callErr))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
reason := parseRevertReason(revertData)
|
|
if reason == "" {
|
|
return fmt.Sprintf("0x%s", hex.EncodeToString(revertData))
|
|
}
|
|
return reason
|
|
}
|
|
|
|
func parseRevertReason(data []byte) string {
|
|
if len(data) < 4 {
|
|
return ""
|
|
}
|
|
|
|
selector := data[:4]
|
|
payload := data[4:]
|
|
|
|
switch {
|
|
case bytes.Equal(selector, revertErrorSelector):
|
|
strType, err := abi.NewType("string", "", nil)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
args := abi.Arguments{{Type: strType}}
|
|
values, err := args.Unpack(payload)
|
|
if err != nil || len(values) == 0 {
|
|
return ""
|
|
}
|
|
if reason, ok := values[0].(string); ok {
|
|
return reason
|
|
}
|
|
case bytes.Equal(selector, revertPanicSelector):
|
|
uintType, err := abi.NewType("uint256", "", nil)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
args := abi.Arguments{{Type: uintType}}
|
|
values, err := args.Unpack(payload)
|
|
if err != nil || len(values) == 0 {
|
|
return ""
|
|
}
|
|
if code, ok := values[0].(*big.Int); ok {
|
|
return fmt.Sprintf("panic code 0x%s", strings.ToLower(code.Text(16)))
|
|
}
|
|
default:
|
|
// Some contracts return raw reason strings without selector
|
|
if utf8String := extractUTF8String(data); utf8String != "" {
|
|
return utf8String
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func extractUTF8String(data []byte) string {
|
|
if len(data) == 0 {
|
|
return ""
|
|
}
|
|
|
|
trimmed := bytes.Trim(data, "\x00")
|
|
if len(trimmed) == 0 {
|
|
return ""
|
|
}
|
|
|
|
for _, b := range trimmed {
|
|
// Allow printable ASCII range plus common whitespace
|
|
if (b < 0x20 || b > 0x7E) && b != 0x0a && b != 0x0d && b != 0x09 {
|
|
return ""
|
|
}
|
|
}
|
|
|
|
return string(trimmed)
|
|
}
|
|
|
|
// calculateActualProfit calculates the actual profit from the transaction
|
|
func (executor *FlashSwapExecutor) calculateActualProfit(receipt *types.Receipt, opportunity *pkgtypes.ArbitrageOpportunity) (*math.UniversalDecimal, error) {
|
|
if receipt == nil {
|
|
return nil, fmt.Errorf("transaction receipt cannot be nil")
|
|
}
|
|
|
|
gasCostWei := executor.calculateGasCostWei(receipt)
|
|
|
|
var parsedEvent *contracts.ArbitrageExecutorArbitrageExecuted
|
|
if executor.arbitrageBinding != nil {
|
|
for _, log := range receipt.Logs {
|
|
if log.Address != executor.arbitrageContract {
|
|
continue
|
|
}
|
|
|
|
event, err := executor.arbitrageBinding.ParseArbitrageExecuted(*log)
|
|
if err != nil {
|
|
if executor.logger != nil {
|
|
executor.logger.Debug("Failed to parse arbitrage execution log", "error", err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
parsedEvent = event
|
|
break
|
|
}
|
|
}
|
|
|
|
profitAmount := executor.extractProfitAmount(parsedEvent, opportunity)
|
|
if profitAmount == nil {
|
|
return nil, fmt.Errorf("unable to determine profit from receipt or opportunity")
|
|
}
|
|
|
|
profitToken, descriptor := executor.resolveProfitDescriptor(parsedEvent, opportunity)
|
|
|
|
gasCostInToken := executor.convertGasCostToTokenUnits(gasCostWei, profitToken, descriptor)
|
|
|
|
netProfit := new(big.Int).Set(profitAmount)
|
|
if gasCostInToken != nil {
|
|
netProfit.Sub(netProfit, gasCostInToken)
|
|
}
|
|
|
|
actualProfitDecimal, err := math.NewUniversalDecimal(netProfit, descriptor.Decimals, descriptor.Symbol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return actualProfitDecimal, nil
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) calculateGasCostWei(receipt *types.Receipt) *big.Int {
|
|
if receipt == nil {
|
|
return big.NewInt(0)
|
|
}
|
|
|
|
gasUsedBigInt := new(big.Int).SetUint64(receipt.GasUsed)
|
|
gasPrice := receipt.EffectiveGasPrice
|
|
if gasPrice == nil {
|
|
gasPrice = big.NewInt(0)
|
|
}
|
|
|
|
return new(big.Int).Mul(gasUsedBigInt, gasPrice)
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) extractProfitAmount(event *contracts.ArbitrageExecutorArbitrageExecuted, opportunity *pkgtypes.ArbitrageOpportunity) *big.Int {
|
|
if event != nil && event.Profit != nil {
|
|
return new(big.Int).Set(event.Profit)
|
|
}
|
|
|
|
if opportunity == nil {
|
|
return nil
|
|
}
|
|
|
|
if opportunity.NetProfit != nil && opportunity.NetProfit.Sign() > 0 {
|
|
return new(big.Int).Set(opportunity.NetProfit)
|
|
}
|
|
|
|
if estimate, err := executor.profitEstimateWei(opportunity); err == nil {
|
|
return estimate
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) resolveProfitDescriptor(event *contracts.ArbitrageExecutorArbitrageExecuted, opportunity *pkgtypes.ArbitrageOpportunity) (common.Address, tokenDescriptor) {
|
|
var tokenAddr common.Address
|
|
|
|
if event != nil && len(event.Tokens) > 0 {
|
|
tokenAddr = event.Tokens[len(event.Tokens)-1]
|
|
} else if opportunity != nil && opportunity.TokenOut != (common.Address{}) {
|
|
tokenAddr = opportunity.TokenOut
|
|
}
|
|
|
|
descriptor, ok := executor.tokenRegistry[tokenAddr]
|
|
if !ok {
|
|
if opportunity != nil && opportunity.Quantities != nil {
|
|
descriptor.Symbol = opportunity.Quantities.NetProfit.Symbol
|
|
descriptor.Decimals = opportunity.Quantities.NetProfit.Decimals
|
|
}
|
|
if descriptor.Symbol == "" {
|
|
descriptor.Symbol = "ETH"
|
|
}
|
|
if descriptor.Decimals == 0 {
|
|
descriptor.Decimals = 18
|
|
}
|
|
} else {
|
|
// Copy to avoid mutating registry entry
|
|
descriptor = tokenDescriptor{
|
|
Symbol: descriptor.Symbol,
|
|
Decimals: descriptor.Decimals,
|
|
PriceUSD: descriptor.PriceUSD,
|
|
}
|
|
}
|
|
|
|
return tokenAddr, descriptor
|
|
}
|
|
|
|
func (executor *FlashSwapExecutor) convertGasCostToTokenUnits(gasCostWei *big.Int, tokenAddr common.Address, descriptor tokenDescriptor) *big.Int {
|
|
if gasCostWei == nil || gasCostWei.Sign() == 0 {
|
|
return big.NewInt(0)
|
|
}
|
|
|
|
if tokenAddr == (common.Address{}) || tokenAddr == executor.ethReferenceToken || strings.EqualFold(descriptor.Symbol, "ETH") || strings.EqualFold(descriptor.Symbol, "WETH") {
|
|
return new(big.Int).Set(gasCostWei)
|
|
}
|
|
|
|
if descriptor.PriceUSD == nil {
|
|
if executor.logger != nil {
|
|
executor.logger.Debug("Gas cost conversion skipped due to missing price data", "token", descriptor.Symbol)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
ethDescriptor, ok := executor.tokenRegistry[executor.ethReferenceToken]
|
|
if !ok || ethDescriptor.PriceUSD == nil {
|
|
if executor.logger != nil {
|
|
executor.logger.Debug("Gas cost conversion skipped due to missing ETH pricing")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
numerator := new(big.Rat).SetInt(gasCostWei)
|
|
numerator.Mul(numerator, ethDescriptor.PriceUSD)
|
|
|
|
denominator := new(big.Rat).Mul(new(big.Rat).SetInt(powerOfTenInt(18)), descriptor.PriceUSD)
|
|
if denominator.Sign() == 0 {
|
|
return nil
|
|
}
|
|
|
|
tokenAmount := new(big.Rat).Quo(numerator, denominator)
|
|
tokenAmount.Mul(tokenAmount, new(big.Rat).SetInt(powerOfTenUint(descriptor.Decimals)))
|
|
|
|
// Floor conversion to avoid overstating deductions
|
|
result := new(big.Int).Quo(tokenAmount.Num(), tokenAmount.Denom())
|
|
if result.Sign() == 0 && tokenAmount.Sign() > 0 {
|
|
// Ensure non-zero deduction when value exists to avoid under-accounting
|
|
result = big.NewInt(1)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func defaultTokenRegistry() map[common.Address]tokenDescriptor {
|
|
registry := map[common.Address]tokenDescriptor{
|
|
{}: newTokenDescriptor("ETH", 18, 2000.0),
|
|
common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"): newTokenDescriptor("WETH", 18, 2000.0),
|
|
common.HexToAddress("0xaF88d065e77c8cC2239327C5EDb3A432268e5831"): newTokenDescriptor("USDC", 6, 1.0),
|
|
common.HexToAddress("0xff970a61a04b1ca14834a43f5de4533ebddb5cc8"): newTokenDescriptor("USDC.e", 6, 1.0),
|
|
common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"): newTokenDescriptor("USDT", 6, 1.0),
|
|
common.HexToAddress("0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f"): newTokenDescriptor("WBTC", 8, 43000.0),
|
|
common.HexToAddress("0x912CE59144191C1204E64559FE8253a0e49E6548"): newTokenDescriptor("ARB", 18, 0.75),
|
|
common.HexToAddress("0xfc5a1a6eb076a2c7ad06ed22c90d7e710e35ad0a"): newTokenDescriptor("GMX", 18, 45.0),
|
|
common.HexToAddress("0xf97f4df75117a78c1a5a0dbb814af92458539fb4"): newTokenDescriptor("LINK", 18, 12.0),
|
|
common.HexToAddress("0xfa7f8980b0f1e64a2062791cc3b0871572f1f7f0"): newTokenDescriptor("UNI", 18, 8.0),
|
|
common.HexToAddress("0xba5ddd1f9d7f570dc94a51479a000e3bce967196"): newTokenDescriptor("AAVE", 18, 85.0),
|
|
}
|
|
|
|
return registry
|
|
}
|
|
|
|
func newTokenDescriptor(symbol string, decimals uint8, price float64) tokenDescriptor {
|
|
desc := tokenDescriptor{
|
|
Symbol: symbol,
|
|
Decimals: decimals,
|
|
}
|
|
|
|
if price > 0 {
|
|
desc.PriceUSD = new(big.Rat).SetFloat64(price)
|
|
}
|
|
|
|
return desc
|
|
}
|
|
|
|
func powerOfTenInt(exp int) *big.Int {
|
|
return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(exp)), nil)
|
|
}
|
|
|
|
func powerOfTenUint(exp uint8) *big.Int {
|
|
return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(exp)), nil)
|
|
}
|
|
|
|
// createSuccessfulResult creates a successful execution result
|
|
func (executor *FlashSwapExecutor) createSuccessfulResult(state *ExecutionState, receipt *types.Receipt) *ExecutionResult {
|
|
// Convert UniversalDecimal to big.Int for ProfitRealized
|
|
profitRealized := big.NewInt(0)
|
|
if state.ActualProfit != nil {
|
|
profitRealized = new(big.Int).Set(state.ActualProfit.Value)
|
|
} else if state.Opportunity != nil && state.Opportunity.NetProfit != nil {
|
|
profitRealized = new(big.Int).Set(state.Opportunity.NetProfit)
|
|
} else if estimate, err := executor.profitEstimateWei(state.Opportunity); err == nil {
|
|
profitRealized = estimate
|
|
}
|
|
|
|
// Create a minimal ArbitragePath based on the opportunity
|
|
path := &ArbitragePath{
|
|
Tokens: []common.Address{state.Opportunity.TokenIn, state.Opportunity.TokenOut}, // Basic 2-token path
|
|
Pools: []*PoolInfo{}, // Empty for now
|
|
Protocols: []string{}, // Empty for now
|
|
Fees: []int64{}, // Empty for now
|
|
EstimatedGas: big.NewInt(0), // To be calculated
|
|
NetProfit: profitRealized,
|
|
ROI: 0, // To be calculated
|
|
LastUpdated: time.Now(),
|
|
}
|
|
|
|
gasUsedBigInt := new(big.Int).SetUint64(receipt.GasUsed)
|
|
gasCost := new(big.Int).Mul(gasUsedBigInt, receipt.EffectiveGasPrice)
|
|
gasCostDecimal, _ := math.NewUniversalDecimal(gasCost, 18, "ETH")
|
|
|
|
return &ExecutionResult{
|
|
TransactionHash: state.TransactionHash,
|
|
GasUsed: receipt.GasUsed,
|
|
GasPrice: receipt.EffectiveGasPrice,
|
|
GasCost: gasCostDecimal,
|
|
ProfitRealized: profitRealized,
|
|
Success: true,
|
|
Error: nil,
|
|
ErrorMessage: "",
|
|
Status: "Success",
|
|
ExecutionTime: time.Since(state.StartTime),
|
|
Path: path,
|
|
}
|
|
}
|
|
|
|
// createFailedResult creates a failed execution result
|
|
func (executor *FlashSwapExecutor) createFailedResult(state *ExecutionState, err error) *ExecutionResult {
|
|
// Create a minimal ArbitragePath based on the opportunity
|
|
path := &ArbitragePath{
|
|
Tokens: []common.Address{state.Opportunity.TokenIn, state.Opportunity.TokenOut}, // Basic 2-token path
|
|
Pools: []*PoolInfo{}, // Empty for now
|
|
Protocols: []string{}, // Empty for now
|
|
Fees: []int64{}, // Empty for now
|
|
EstimatedGas: big.NewInt(0), // To be calculated
|
|
NetProfit: big.NewInt(0),
|
|
ROI: 0, // To be calculated
|
|
LastUpdated: time.Now(),
|
|
}
|
|
|
|
gasCostDecimal, _ := math.NewUniversalDecimal(big.NewInt(0), 18, "ETH")
|
|
|
|
return &ExecutionResult{
|
|
TransactionHash: state.TransactionHash,
|
|
GasUsed: 0,
|
|
GasPrice: big.NewInt(0),
|
|
GasCost: gasCostDecimal,
|
|
ProfitRealized: big.NewInt(0),
|
|
Success: false,
|
|
Error: err,
|
|
ErrorMessage: err.Error(),
|
|
Status: "Failed",
|
|
ExecutionTime: time.Since(state.StartTime),
|
|
Path: path,
|
|
}
|
|
}
|
|
|
|
// isRetryableError determines if an error is retryable
|
|
func (executor *FlashSwapExecutor) isRetryableError(errorMsg string) bool {
|
|
retryableErrors := []string{
|
|
"gas price too low",
|
|
"nonce too low",
|
|
"timeout",
|
|
"network error",
|
|
"connection refused",
|
|
"transaction underpriced",
|
|
"replacement transaction underpriced",
|
|
"known transaction",
|
|
}
|
|
|
|
for _, retryable := range retryableErrors {
|
|
if strings.Contains(strings.ToLower(errorMsg), strings.ToLower(retryable)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// updateGasPriceForRetry updates gas price for retry attempts
|
|
func (executor *FlashSwapExecutor) updateGasPriceForRetry(
|
|
ctx context.Context,
|
|
transactOpts *bind.TransactOpts,
|
|
attempt int,
|
|
) (*bind.TransactOpts, error) {
|
|
|
|
// Increase gas price by 20% for each retry
|
|
multiplier := 1.0 + float64(attempt)*0.2
|
|
|
|
if transactOpts.GasFeeCap != nil {
|
|
newGasFeeCap := new(big.Float).Mul(
|
|
new(big.Float).SetInt(transactOpts.GasFeeCap),
|
|
big.NewFloat(multiplier),
|
|
)
|
|
newGasFeeCapInt, _ := newGasFeeCap.Int(nil)
|
|
transactOpts.GasFeeCap = newGasFeeCapInt
|
|
}
|
|
|
|
if transactOpts.GasTipCap != nil {
|
|
newGasTipCap := new(big.Float).Mul(
|
|
new(big.Float).SetInt(transactOpts.GasTipCap),
|
|
big.NewFloat(multiplier),
|
|
)
|
|
newGasTipCapInt, _ := newGasTipCap.Int(nil)
|
|
transactOpts.GasTipCap = newGasTipCapInt
|
|
}
|
|
|
|
executor.logger.Debug(fmt.Sprintf("Updated gas prices for retry %d: MaxFee=%s, Priority=%s",
|
|
attempt,
|
|
transactOpts.GasFeeCap.String(),
|
|
transactOpts.GasTipCap.String()))
|
|
|
|
return transactOpts, nil
|
|
}
|
|
|
|
// updateExecutionStats updates execution statistics
|
|
func (executor *FlashSwapExecutor) updateExecutionStats(result *ExecutionResult) {
|
|
executor.executionHistory = append(executor.executionHistory, result)
|
|
|
|
if result.Success && result.ProfitRealized != nil {
|
|
profitDecimal, _ := math.NewUniversalDecimal(result.ProfitRealized, 18, "ETH")
|
|
executor.totalProfit, _ = executor.decimalConverter.Add(executor.totalProfit, profitDecimal)
|
|
}
|
|
|
|
if result.GasCost != nil {
|
|
executor.totalGasCost, _ = executor.decimalConverter.Add(executor.totalGasCost, result.GasCost)
|
|
}
|
|
|
|
// Clean up pending executions
|
|
delete(executor.pendingExecutions, result.TransactionHash)
|
|
|
|
// Keep only last 100 execution results
|
|
if len(executor.executionHistory) > 100 {
|
|
executor.executionHistory = executor.executionHistory[len(executor.executionHistory)-100:]
|
|
}
|
|
}
|
|
|
|
// GetExecutionStats returns execution statistics
|
|
func (executor *FlashSwapExecutor) GetExecutionStats() ExecutionStats {
|
|
successCount := 0
|
|
totalExecutions := len(executor.executionHistory)
|
|
|
|
for _, result := range executor.executionHistory {
|
|
if result.Success {
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
successRate := 0.0
|
|
if totalExecutions > 0 {
|
|
successRate = float64(successCount) / float64(totalExecutions) * 100
|
|
}
|
|
|
|
return ExecutionStats{
|
|
TotalExecutions: totalExecutions,
|
|
SuccessfulExecutions: successCount,
|
|
SuccessRate: successRate,
|
|
TotalProfit: executor.totalProfit,
|
|
TotalGasCost: executor.totalGasCost,
|
|
PendingExecutions: len(executor.pendingExecutions),
|
|
}
|
|
}
|
|
|
|
// ExecutionStats contains execution statistics
|
|
type ExecutionStats struct {
|
|
TotalExecutions int
|
|
SuccessfulExecutions int
|
|
SuccessRate float64
|
|
TotalProfit *math.UniversalDecimal
|
|
TotalGasCost *math.UniversalDecimal
|
|
PendingExecutions int
|
|
}
|
|
|
|
// GetPendingExecutions returns currently pending executions
|
|
func (executor *FlashSwapExecutor) GetPendingExecutions() map[common.Hash]*ExecutionState {
|
|
return executor.pendingExecutions
|
|
}
|
|
|
|
// GetExecutionHistory returns recent execution history
|
|
func (executor *FlashSwapExecutor) GetExecutionHistory(limit int) []*ExecutionResult {
|
|
if limit <= 0 || limit > len(executor.executionHistory) {
|
|
limit = len(executor.executionHistory)
|
|
}
|
|
|
|
start := len(executor.executionHistory) - limit
|
|
return executor.executionHistory[start:]
|
|
}
|