feat: create v2-prep branch with comprehensive planning

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>
This commit is contained in:
Administrator
2025-11-10 10:14:26 +01:00
parent 1773daffe7
commit 803de231ba
411 changed files with 20390 additions and 8680 deletions

View File

@@ -0,0 +1,423 @@
package arbitrage
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math/big"
"time"
"github.com/ethereum/go-ethereum/common"
_ "github.com/mattn/go-sqlite3"
"github.com/fraktal/mev-beta/internal/logger"
pkgtypes "github.com/fraktal/mev-beta/pkg/types"
)
// SQLiteDatabase implements ArbitrageDatabase using SQLite
type SQLiteDatabase struct {
db *sql.DB
logger *logger.Logger
}
// NewSQLiteDatabase creates a new SQLite database for arbitrage data
func NewSQLiteDatabase(dbPath string, logger *logger.Logger) (*SQLiteDatabase, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
database := &SQLiteDatabase{
db: db,
logger: logger,
}
if err := database.createTables(); err != nil {
return nil, fmt.Errorf("failed to create tables: %w", err)
}
return database, nil
}
// createTables creates the necessary database tables
func (db *SQLiteDatabase) createTables() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS arbitrage_opportunities (
id TEXT PRIMARY KEY,
path_json TEXT NOT NULL,
trigger_event_json TEXT NOT NULL,
detected_at INTEGER NOT NULL,
estimated_profit TEXT NOT NULL,
required_amount TEXT NOT NULL,
urgency INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`,
`CREATE TABLE IF NOT EXISTS arbitrage_executions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
opportunity_id TEXT,
transaction_hash TEXT NOT NULL,
gas_used INTEGER NOT NULL,
gas_price TEXT NOT NULL,
profit_realized TEXT NOT NULL,
success BOOLEAN NOT NULL,
error_message TEXT,
execution_time_ms INTEGER NOT NULL,
path_json TEXT NOT NULL,
executed_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY(opportunity_id) REFERENCES arbitrage_opportunities(id)
)`,
`CREATE TABLE IF NOT EXISTS pool_data (
address TEXT PRIMARY KEY,
token0 TEXT NOT NULL,
token1 TEXT NOT NULL,
protocol TEXT NOT NULL,
fee INTEGER NOT NULL,
liquidity TEXT NOT NULL,
sqrt_price_x96 TEXT NOT NULL,
tick INTEGER,
block_number INTEGER NOT NULL,
transaction_hash TEXT NOT NULL,
log_index INTEGER NOT NULL,
last_updated INTEGER NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`,
`CREATE TABLE IF NOT EXISTS swap_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transaction_hash TEXT NOT NULL,
pool_address TEXT NOT NULL,
token0 TEXT NOT NULL,
token1 TEXT NOT NULL,
amount0 TEXT NOT NULL,
amount1 TEXT NOT NULL,
sqrt_price_x96 TEXT NOT NULL,
liquidity TEXT NOT NULL,
tick INTEGER,
block_number INTEGER NOT NULL,
log_index INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`,
}
for _, query := range queries {
if _, err := db.db.Exec(query); err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
}
// Create indexes for better performance
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_opportunities_detected_at ON arbitrage_opportunities(detected_at)`,
`CREATE INDEX IF NOT EXISTS idx_opportunities_urgency ON arbitrage_opportunities(urgency)`,
`CREATE INDEX IF NOT EXISTS idx_executions_executed_at ON arbitrage_executions(executed_at)`,
`CREATE INDEX IF NOT EXISTS idx_executions_success ON arbitrage_executions(success)`,
`CREATE INDEX IF NOT EXISTS idx_pool_data_updated ON pool_data(last_updated)`,
`CREATE INDEX IF NOT EXISTS idx_pool_data_tokens ON pool_data(token0, token1)`,
`CREATE INDEX IF NOT EXISTS idx_swap_events_pool ON swap_events(pool_address)`,
`CREATE INDEX IF NOT EXISTS idx_swap_events_block ON swap_events(block_number)`,
`CREATE INDEX IF NOT EXISTS idx_swap_events_timestamp ON swap_events(timestamp)`,
}
for _, index := range indexes {
if _, err := db.db.Exec(index); err != nil {
return fmt.Errorf("failed to create index: %w", err)
}
}
return nil
}
// SaveOpportunity saves an arbitrage opportunity to the database
func (db *SQLiteDatabase) SaveOpportunity(ctx context.Context, opportunity *pkgtypes.ArbitrageOpportunity) error {
pathJSON, err := json.Marshal(opportunity.Path)
if err != nil {
return fmt.Errorf("failed to marshal path: %w", err)
}
// Create empty trigger event for compatibility
triggerEvent := map[string]interface{}{
"protocol": opportunity.Protocol,
"tokenIn": opportunity.TokenIn.Hex(),
"tokenOut": opportunity.TokenOut.Hex(),
}
eventJSON, err := json.Marshal(triggerEvent)
if err != nil {
return fmt.Errorf("failed to marshal trigger event: %w", err)
}
// Generate a simple ID from timestamp and token addresses
opportunityID := fmt.Sprintf("%s_%s_%d",
opportunity.TokenIn.Hex()[:8],
opportunity.TokenOut.Hex()[:8],
opportunity.Timestamp)
query := `INSERT INTO arbitrage_opportunities
(id, path_json, trigger_event_json, detected_at, estimated_profit, required_amount, urgency, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
_, err = db.db.ExecContext(ctx, query,
opportunityID,
string(pathJSON),
string(eventJSON),
opportunity.Timestamp,
opportunity.Profit.String(),
opportunity.AmountIn.String(),
1, // Default urgency
opportunity.Timestamp+3600, // Expires in 1 hour
)
if err != nil {
return fmt.Errorf("failed to save opportunity: %w", err)
}
db.logger.Debug(fmt.Sprintf("Saved arbitrage opportunity %s to database", opportunityID))
return nil
}
// SaveExecution saves an arbitrage execution result to the database
func (db *SQLiteDatabase) SaveExecution(ctx context.Context, result *ExecutionResult) error {
pathJSON, err := json.Marshal(result.Path)
if err != nil {
return fmt.Errorf("failed to marshal path: %w", err)
}
var opportunityID *string
if result.Path != nil {
// Try to find the opportunity ID from the path
// This is a simplified approach - in production you'd want better linking
id := generateOpportunityIDFromPath(result.Path)
opportunityID = &id
}
var errorMessage *string
if result.Error != nil {
msg := result.Error.Error()
errorMessage = &msg
}
query := `INSERT INTO arbitrage_executions
(opportunity_id, transaction_hash, gas_used, gas_price, profit_realized, success,
error_message, execution_time_ms, path_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err = db.db.ExecContext(ctx, query,
opportunityID,
result.TransactionHash.Hex(),
result.GasUsed,
result.GasPrice.String(),
result.ProfitRealized.String(),
result.Success,
errorMessage,
result.ExecutionTime.Milliseconds(),
string(pathJSON),
)
if err != nil {
return fmt.Errorf("failed to save execution: %w", err)
}
db.logger.Debug(fmt.Sprintf("Saved arbitrage execution %s to database", result.TransactionHash.Hex()))
return nil
}
// GetExecutionHistory retrieves historical arbitrage executions
func (db *SQLiteDatabase) GetExecutionHistory(ctx context.Context, limit int) ([]*ExecutionResult, error) {
query := `SELECT transaction_hash, gas_used, gas_price, profit_realized, success,
error_message, execution_time_ms, path_json, executed_at
FROM arbitrage_executions
ORDER BY executed_at DESC
LIMIT ?`
rows, err := db.db.QueryContext(ctx, query, limit)
if err != nil {
return nil, fmt.Errorf("failed to query execution history: %w", err)
}
defer rows.Close()
var results []*ExecutionResult
for rows.Next() {
var txHashStr, gasPriceStr, profitStr, pathJSON string
var gasUsed, executionTimeMs, executedAt int64
var success bool
var errorMessage *string
err := rows.Scan(&txHashStr, &gasUsed, &gasPriceStr, &profitStr, &success,
&errorMessage, &executionTimeMs, &pathJSON, &executedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan execution row: %w", err)
}
// Parse path JSON
var path ArbitragePath
if err := json.Unmarshal([]byte(pathJSON), &path); err != nil {
db.logger.Warn(fmt.Sprintf("Failed to unmarshal path JSON: %v", err))
continue
}
result := &ExecutionResult{
TransactionHash: common.HexToHash(txHashStr),
GasUsed: uint64(gasUsed),
Success: success,
ExecutionTime: time.Duration(executionTimeMs) * time.Millisecond,
Path: &path,
}
// Parse gas price and profit
result.GasPrice, _ = parseBigInt(gasPriceStr)
result.ProfitRealized, _ = parseBigInt(profitStr)
// Parse error message
if errorMessage != nil {
result.Error = fmt.Errorf("execution error: %s", *errorMessage)
}
results = append(results, result)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return results, nil
}
// SavePoolData saves pool data to the database
func (db *SQLiteDatabase) SavePoolData(ctx context.Context, poolData *SimplePoolData) error {
query := `INSERT OR REPLACE INTO pool_data
(address, token0, token1, protocol, fee, liquidity, sqrt_price_x96, tick,
block_number, transaction_hash, log_index, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.db.ExecContext(ctx, query,
poolData.Address.Hex(),
poolData.Token0.Hex(),
poolData.Token1.Hex(),
"UniswapV3", // Default protocol
poolData.Fee,
poolData.Liquidity.String(),
poolData.SqrtPriceX96.String(),
poolData.Tick,
poolData.BlockNumber,
poolData.TxHash.Hex(),
poolData.LogIndex,
poolData.LastUpdated.Unix(),
)
if err != nil {
return fmt.Errorf("failed to save pool data: %w", err)
}
db.logger.Debug(fmt.Sprintf("Saved pool data %s to database", poolData.Address.Hex()))
return nil
}
// GetPoolData retrieves pool data from the database
func (db *SQLiteDatabase) GetPoolData(ctx context.Context, poolAddress common.Address) (*SimplePoolData, error) {
query := `SELECT address, token0, token1, protocol, fee, liquidity, sqrt_price_x96, tick,
block_number, transaction_hash, log_index, last_updated
FROM pool_data WHERE address = ?`
row := db.db.QueryRowContext(ctx, query, poolAddress.Hex())
var addrStr, token0Str, token1Str, protocol, liquidityStr, sqrtPriceStr, txHashStr string
var fee, tick, blockNumber, logIndex, lastUpdated int64
err := row.Scan(&addrStr, &token0Str, &token1Str, &protocol, &fee,
&liquidityStr, &sqrtPriceStr, &tick, &blockNumber, &txHashStr, &logIndex, &lastUpdated)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("pool data not found")
}
return nil, fmt.Errorf("failed to query pool data: %w", err)
}
// Parse the data
liquidity, ok1 := parseBigInt(liquidityStr)
sqrtPriceX96, ok2 := parseBigInt(sqrtPriceStr)
if !ok1 || !ok2 {
return nil, fmt.Errorf("failed to parse pool numeric data")
}
poolData := &SimplePoolData{
Address: common.HexToAddress(addrStr),
Token0: common.HexToAddress(token0Str),
Token1: common.HexToAddress(token1Str),
Fee: fee,
Liquidity: liquidity,
SqrtPriceX96: sqrtPriceX96,
Tick: int32(tick),
BlockNumber: uint64(blockNumber),
TxHash: common.HexToHash(txHashStr),
LogIndex: uint(logIndex),
LastUpdated: time.Unix(lastUpdated, 0),
}
return poolData, nil
}
// GetOpportunityCount returns the total number of opportunities detected
func (db *SQLiteDatabase) GetOpportunityCount(ctx context.Context) (int64, error) {
var count int64
err := db.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM arbitrage_opportunities").Scan(&count)
return count, err
}
// GetExecutionCount returns the total number of executions attempted
func (db *SQLiteDatabase) GetExecutionCount(ctx context.Context) (int64, error) {
var count int64
err := db.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM arbitrage_executions").Scan(&count)
return count, err
}
// GetSuccessfulExecutionCount returns the number of successful executions
func (db *SQLiteDatabase) GetSuccessfulExecutionCount(ctx context.Context) (int64, error) {
var count int64
err := db.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM arbitrage_executions WHERE success = 1").Scan(&count)
return count, err
}
// GetTotalProfit returns the total profit realized from all successful executions
func (db *SQLiteDatabase) GetTotalProfit(ctx context.Context) (string, error) {
var totalProfit sql.NullString
err := db.db.QueryRowContext(ctx,
"SELECT SUM(CAST(profit_realized AS REAL)) FROM arbitrage_executions WHERE success = 1").Scan(&totalProfit)
if err != nil {
return "0", err
}
if !totalProfit.Valid {
return "0", nil
}
return totalProfit.String, nil
}
// Close closes the database connection
func (db *SQLiteDatabase) Close() error {
return db.db.Close()
}
// Helper functions
// parseBigInt safely parses a big integer from string
func parseBigInt(s string) (*big.Int, bool) {
result := new(big.Int)
result, ok := result.SetString(s, 10)
return result, ok
}
// generateOpportunityIDFromPath generates a consistent ID from path data
func generateOpportunityIDFromPath(path *ArbitragePath) string {
if path == nil || len(path.Tokens) == 0 {
return fmt.Sprintf("unknown_%d", time.Now().UnixNano())
}
return fmt.Sprintf("path_%s_%d", path.Tokens[0].Hex()[:8], time.Now().UnixNano())
}

View File

@@ -0,0 +1,90 @@
package arbitrage
import (
"fmt"
"math/big"
"github.com/fraktal/mev-beta/pkg/math"
)
var sharedDecimalConverter = math.NewDecimalConverter()
// universalFromWei converts a wei-denominated big.Int into a UniversalDecimal with 18 decimals.
// It gracefully handles nil values by returning a zero amount.
func universalFromWei(dec *math.DecimalConverter, value *big.Int, symbol string) *math.UniversalDecimal {
if dec == nil {
dec = sharedDecimalConverter
}
if value == nil {
zero, _ := math.NewUniversalDecimal(big.NewInt(0), 18, symbol)
return zero
}
return dec.FromWei(value, 18, symbol)
}
func universalOrFromWei(dec *math.DecimalConverter, decimal *math.UniversalDecimal, fallback *big.Int, decimals uint8, symbol string) *math.UniversalDecimal {
if decimal != nil {
return decimal
}
if fallback == nil {
zero, _ := math.NewUniversalDecimal(big.NewInt(0), decimals, symbol)
return zero
}
if dec == nil {
dc := sharedDecimalConverter
return dc.FromWei(fallback, decimals, symbol)
}
return dec.FromWei(fallback, decimals, symbol)
}
func floatStringFromDecimal(ud *math.UniversalDecimal, precision int) string {
if precision < 0 {
precision = int(ud.Decimals)
}
if ud == nil {
if precision <= 0 {
return "0"
}
return fmt.Sprintf("0.%0*d", precision, 0)
}
if ud.Decimals == 0 {
return ud.Value.String()
}
numerator := new(big.Int).Set(ud.Value)
denominator := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(ud.Decimals)), nil)
rat := new(big.Rat).SetFrac(numerator, denominator)
return rat.FloatString(precision)
}
func ethAmountString(dec *math.DecimalConverter, decimal *math.UniversalDecimal, wei *big.Int) string {
if dec == nil {
dec = sharedDecimalConverter
}
ud := universalOrFromWei(dec, decimal, wei, 18, "ETH")
return floatStringFromDecimal(ud, 6)
}
func gweiAmountString(dec *math.DecimalConverter, decimal *math.UniversalDecimal, wei *big.Int) string {
if dec == nil {
dec = sharedDecimalConverter
}
if decimal != nil {
return floatStringFromDecimal(decimal, 2)
}
if wei == nil {
return "0.00"
}
numerator := new(big.Rat).SetInt(wei)
denominator := new(big.Rat).SetInt(big.NewInt(1_000_000_000))
return new(big.Rat).Quo(numerator, denominator).FloatString(2)
}
// Removed unused format functions - moved to examples/profitability_demo.go if needed

View File

@@ -0,0 +1,38 @@
package arbitrage
import (
"math/big"
"testing"
"github.com/fraktal/mev-beta/pkg/math"
)
func TestEthAmountStringPrefersDecimalSnapshot(t *testing.T) {
ud, err := math.NewUniversalDecimal(big.NewInt(1500000000000000000), 18, "ETH")
if err != nil {
t.Fatalf("unexpected error creating decimal: %v", err)
}
got := ethAmountString(nil, ud, nil)
if got != "1.500000" {
t.Fatalf("expected 1.500000, got %s", got)
}
}
func TestEthAmountStringFallsBackToWei(t *testing.T) {
wei := big.NewInt(123450000000000000)
got := ethAmountString(nil, nil, wei)
if got != "0.123450" {
t.Fatalf("expected 0.123450, got %s", got)
}
}
func TestGweiAmountStringFormatsTwoDecimals(t *testing.T) {
wei := big.NewInt(987654321)
got := gweiAmountString(nil, nil, wei)
if got != "0.99" {
t.Fatalf("expected 0.99, got %s", got)
}
}

View File

@@ -0,0 +1,975 @@
// Package arbitrage provides the core arbitrage detection and analysis engine
// for the MEV bot. This package is responsible for identifying profitable
// arbitrage opportunities across multiple DEX protocols on Arbitrum.
//
// The detection engine continuously scans for price discrepancies between
// exchanges and calculates potential profits after accounting for gas costs,
// slippage, and other factors. It uses advanced mathematical models to
// optimize trade sizing and minimize risks.
package arbitrage
import (
"context"
"fmt"
"math/big"
"sync"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/fraktal/mev-beta/internal/logger"
"github.com/fraktal/mev-beta/pkg/exchanges"
"github.com/fraktal/mev-beta/pkg/math"
"github.com/fraktal/mev-beta/pkg/types"
)
// ArbitrageDetectionEngine is the core component responsible for discovering
// profitable arbitrage opportunities in real-time across multiple DEX protocols.
// The engine uses sophisticated algorithms to:
//
// 1. Continuously scan token pairs across supported exchanges
// 2. Identify price discrepancies that exceed minimum profit thresholds
// 3. Calculate optimal trade sizes considering slippage and gas costs
// 4. Filter opportunities based on risk and confidence metrics
// 5. Provide real-time opportunity feeds to execution systems
//
// The engine operates with configurable parameters for different trading strategies
// and risk profiles, making it suitable for both conservative and aggressive MEV extraction.
type ArbitrageDetectionEngine struct {
// Core dependencies for arbitrage detection
registry *exchanges.ExchangeRegistry // Registry of supported exchanges and their configurations
calculator *math.ArbitrageCalculator // Mathematical engine for profit calculations
gasEstimator math.GasEstimator // Gas cost estimation for transaction profitability
logger *logger.Logger // Structured logging for monitoring and debugging
decimalConverter *math.DecimalConverter // Handles precision math for token amounts
// Callback function for handling discovered opportunities
// This is typically connected to an execution engine
opportunityHandler func(*types.ArbitrageOpportunity)
// Configuration parameters that control detection behavior
config DetectionConfig
// State management for concurrent operation
runningMutex sync.RWMutex // Protects running state from race conditions
isRunning bool // Indicates if the engine is currently active
stopChan chan struct{} // Channel for graceful shutdown signaling
opportunityChan chan *types.ArbitrageOpportunity // Buffered channel for opportunity distribution
// Performance tracking and metrics
scanCount uint64 // Total number of scans performed since startup
opportunityCount uint64 // Total number of opportunities discovered
lastScanTime time.Time // Timestamp of the most recent scan completion
// Concurrent processing infrastructure
scanWorkers *WorkerPool // Pool of workers for parallel opportunity scanning
pathWorkers *WorkerPool // Pool of workers for complex path analysis
// CRITICAL FIX: Backpressure for opportunity handlers
// Prevents unbounded goroutine creation under high opportunity rate
handlerSemaphore chan struct{} // Limits concurrent handler executions
maxHandlers int // Maximum concurrent handler goroutines
}
// DetectionConfig contains all configuration parameters for the arbitrage detection engine.
// These parameters control scanning behavior, opportunity filtering, and performance characteristics.
// The configuration allows fine-tuning for different trading strategies and risk profiles.
type DetectionConfig struct {
// Scanning timing and concurrency parameters
ScanInterval time.Duration // How frequently to scan for new opportunities
MaxConcurrentScans int // Maximum number of simultaneous scanning operations
MaxConcurrentPaths int // Maximum number of paths to analyze in parallel
// Opportunity filtering criteria - these determine what qualifies as actionable
MinProfitThreshold *math.UniversalDecimal // Minimum profit required to consider an opportunity
MaxPriceImpact *math.UniversalDecimal // Maximum acceptable price impact (slippage)
MaxHops int // Maximum number of hops in a trading path
// Token filtering and prioritization
HighPriorityTokens []common.Address // Tokens to scan more frequently (e.g., WETH, USDC)
TokenWhitelist []common.Address // Only scan these tokens if specified
TokenBlacklist []common.Address // Never scan these tokens (e.g., known scam tokens)
// Exchange selection and weighting
EnabledExchanges []math.ExchangeType // Which exchanges to include in scanning
ExchangeWeights map[math.ExchangeType]float64 // Relative weights for exchange prioritization
// Performance optimization settings
CachePoolData bool // Whether to cache pool data between scans
CacheTTL time.Duration // How long to keep cached data valid
BatchSize int // Number of opportunities to process in each batch
// Risk management constraints
MaxPositionSize *math.UniversalDecimal // Maximum size for any single arbitrage
RequiredConfidence float64 // Minimum confidence score (0.0-1.0) for execution
}
// WorkerPool manages concurrent workers for scanning
type WorkerPool struct {
workers int
taskChan chan ScanTask
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
}
// ScanTask represents a scanning task
type ScanTask struct {
TokenPair exchanges.TokenPair
Exchanges []*exchanges.ExchangeConfig
InputAmount *math.UniversalDecimal
ResultChan chan ScanResult
}
// ScanResult contains the result of a scanning task
type ScanResult struct {
Opportunity *types.ArbitrageOpportunity
Error error
ScanTime time.Duration
}
// NewArbitrageDetectionEngine creates and initializes a new arbitrage detection engine
// with the specified dependencies and configuration. The engine is created in a stopped
// state and must be started explicitly using the Start() method.
//
// Parameters:
// - registry: Exchange registry containing supported DEX configurations
// - gasEstimator: Component for estimating transaction gas costs
// - logger: Structured logger for monitoring and debugging
// - config: Configuration parameters for detection behavior
//
// Returns:
// - *ArbitrageDetectionEngine: Configured but not yet started detection engine
func NewArbitrageDetectionEngine(
registry *exchanges.ExchangeRegistry,
gasEstimator math.GasEstimator,
logger *logger.Logger,
config DetectionConfig,
) *ArbitrageDetectionEngine {
calculator := math.NewArbitrageCalculator(gasEstimator)
engine := &ArbitrageDetectionEngine{
registry: registry,
calculator: calculator,
gasEstimator: gasEstimator,
logger: logger,
decimalConverter: math.NewDecimalConverter(),
config: config,
isRunning: false,
stopChan: make(chan struct{}),
opportunityChan: make(chan *types.ArbitrageOpportunity, 1000), // Buffered channel
maxHandlers: 10, // CRITICAL FIX: Limit to 10 concurrent handlers
handlerSemaphore: make(chan struct{}, 10), // CRITICAL FIX: Backpressure semaphore
}
// Set default configuration if not provided
engine.setDefaultConfig()
return engine
}
// setDefaultConfig sets default configuration values
func (engine *ArbitrageDetectionEngine) setDefaultConfig() {
if engine.config.ScanInterval == 0 {
engine.config.ScanInterval = 1 * time.Second
}
if engine.config.MaxConcurrentScans == 0 {
engine.config.MaxConcurrentScans = 10
}
if engine.config.MaxConcurrentPaths == 0 {
engine.config.MaxConcurrentPaths = 50
}
if engine.config.MinProfitThreshold == nil {
// CRITICAL FIX #1: Reduce minimum profit to 0.00005 ETH (realistic threshold)
// Arbitrum has low gas costs: ~100k-200k gas @ 0.1-0.2 gwei = ~0.0001-0.0002 ETH
// 0.00005 ETH provides ~2-3x gas cost safety margin (optimal for profitability)
// Previous 0.001 ETH threshold killed 95% of viable opportunities
engine.config.MinProfitThreshold, _ = engine.decimalConverter.FromString("0.00005", 18, "ETH")
}
if engine.config.MaxPriceImpact == nil {
engine.config.MaxPriceImpact, _ = engine.decimalConverter.FromString("2", 4, "PERCENT")
}
if engine.config.MaxHops == 0 {
engine.config.MaxHops = 3
}
if engine.config.CacheTTL == 0 {
engine.config.CacheTTL = 30 * time.Second
}
if engine.config.BatchSize == 0 {
engine.config.BatchSize = 20
}
if engine.config.RequiredConfidence == 0 {
engine.config.RequiredConfidence = 0.7
}
if len(engine.config.EnabledExchanges) == 0 {
// Enable all exchanges by default
for _, exchangeConfig := range engine.registry.GetAllExchanges() {
engine.config.EnabledExchanges = append(engine.config.EnabledExchanges, exchangeConfig.Type)
}
}
}
// Start begins the arbitrage detection process and initializes all background workers.
// This method starts the main detection loop, worker pools, and opportunity processing.
// The engine will continue running until Stop() is called or the context is cancelled.
//
// The startup process includes:
// 1. Validating that the engine is not already running
// 2. Initializing worker pools for concurrent processing
// 3. Starting the main detection loop
// 4. Starting the opportunity processing pipeline
//
// Parameters:
// - ctx: Context for controlling the engine lifecycle
//
// Returns:
// - error: Any startup errors
func (engine *ArbitrageDetectionEngine) Start(ctx context.Context) error {
// Ensure thread-safe state management during startup
engine.runningMutex.Lock()
defer engine.runningMutex.Unlock()
if engine.isRunning {
return fmt.Errorf("detection engine is already running")
}
engine.logger.Info("Starting arbitrage detection engine...")
engine.logger.Info(fmt.Sprintf("Configuration - Scan Interval: %v, Max Concurrent Scans: %d, Min Profit: %s ETH",
engine.config.ScanInterval,
engine.config.MaxConcurrentScans,
engine.decimalConverter.ToHumanReadable(engine.config.MinProfitThreshold)))
// Initialize worker pools
if err := engine.initializeWorkerPools(ctx); err != nil {
return fmt.Errorf("failed to initialize worker pools: %w", err)
}
engine.isRunning = true
// Start main detection loop
go engine.detectionLoop(ctx)
// Start opportunity processing
go engine.opportunityProcessor(ctx)
engine.logger.Info("Arbitrage detection engine started successfully")
return nil
}
// Stop halts the arbitrage detection process
func (engine *ArbitrageDetectionEngine) Stop() error {
engine.runningMutex.Lock()
defer engine.runningMutex.Unlock()
if !engine.isRunning {
return fmt.Errorf("detection engine is not running")
}
engine.logger.Info("Stopping arbitrage detection engine...")
// Signal stop
close(engine.stopChan)
// Stop worker pools
if engine.scanWorkers != nil {
engine.scanWorkers.Stop()
}
if engine.pathWorkers != nil {
engine.pathWorkers.Stop()
}
engine.isRunning = false
engine.logger.Info(fmt.Sprintf("Detection engine stopped. Total scans: %d, Opportunities found: %d",
engine.scanCount, engine.opportunityCount))
return nil
}
// initializeWorkerPools sets up worker pools for concurrent processing
func (engine *ArbitrageDetectionEngine) initializeWorkerPools(ctx context.Context) error {
// Initialize scan worker pool
engine.scanWorkers = NewWorkerPool(engine.config.MaxConcurrentScans, ctx)
engine.scanWorkers.Start(engine.processScanTask)
// Initialize path worker pool
engine.pathWorkers = NewWorkerPool(engine.config.MaxConcurrentPaths, ctx)
engine.pathWorkers.Start(engine.processPathTask)
return nil
}
// detectionLoop runs the main detection logic
func (engine *ArbitrageDetectionEngine) detectionLoop(ctx context.Context) {
ticker := time.NewTicker(engine.config.ScanInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
engine.logger.Info("Detection loop stopped due to context cancellation")
return
case <-engine.stopChan:
engine.logger.Info("Detection loop stopped")
return
case <-ticker.C:
engine.performScan(ctx)
}
}
}
// performScan executes a complete arbitrage scanning cycle across all configured
// token pairs and exchanges. This is the core scanning logic that runs periodically
// to discover new arbitrage opportunities.
//
// The scanning process includes:
// 1. Identifying token pairs to analyze
// 2. Determining input amounts to test
// 3. Creating scanning tasks for worker pools
// 4. Processing results and filtering opportunities
//
// Each scan is tracked for performance monitoring and optimization.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
func (engine *ArbitrageDetectionEngine) performScan(ctx context.Context) {
// Track scan performance for monitoring
scanStart := time.Now()
engine.scanCount++ // Increment total scan counter
engine.logger.Debug(fmt.Sprintf("Starting arbitrage scan #%d", engine.scanCount))
// Get token pairs to scan
tokenPairs := engine.getTokenPairsToScan()
// Get input amounts to test
inputAmounts := engine.getInputAmountsToTest()
// Create scan tasks
scanTasks := make([]ScanTask, 0)
for _, pair := range tokenPairs {
// Get exchanges that support this pair
supportingExchanges := engine.registry.GetExchangesForPair(
common.HexToAddress(pair.Token0.Address),
common.HexToAddress(pair.Token1.Address),
)
// Filter enabled exchanges
enabledExchanges := engine.filterEnabledExchanges(supportingExchanges)
if len(enabledExchanges) < 2 {
continue // Need at least 2 exchanges for arbitrage
}
for _, inputAmount := range inputAmounts {
task := ScanTask{
TokenPair: pair,
Exchanges: enabledExchanges,
InputAmount: inputAmount,
ResultChan: make(chan ScanResult, 1),
}
scanTasks = append(scanTasks, task)
}
}
engine.logger.Debug(fmt.Sprintf("Created %d scan tasks for %d token pairs", len(scanTasks), len(tokenPairs)))
// Process scan tasks in batches
engine.processScanTasksBatch(ctx, scanTasks)
scanDuration := time.Since(scanStart)
engine.lastScanTime = time.Now()
engine.logger.Debug(fmt.Sprintf("Completed arbitrage scan #%d in %v", engine.scanCount, scanDuration))
}
// getTokenPairsToScan returns token pairs to scan for arbitrage
func (engine *ArbitrageDetectionEngine) getTokenPairsToScan() []exchanges.TokenPair {
// Get high priority tokens first
highPriorityTokens := engine.registry.GetHighPriorityTokens(10)
// Create pairs from high priority tokens
pairs := make([]exchanges.TokenPair, 0)
for i, token0 := range highPriorityTokens {
for j, token1 := range highPriorityTokens {
if i >= j {
continue // Avoid duplicates and self-pairs
}
// Check if pair is supported
if engine.registry.IsPairSupported(
common.HexToAddress(token0.Address),
common.HexToAddress(token1.Address),
) {
pairs = append(pairs, exchanges.TokenPair{
Token0: token0,
Token1: token1,
})
}
}
}
return pairs
}
// getInputAmountsToTest returns different input amounts to test for arbitrage
func (engine *ArbitrageDetectionEngine) getInputAmountsToTest() []*math.UniversalDecimal {
amounts := make([]*math.UniversalDecimal, 0)
// Test different input amounts to find optimal arbitrage size
testAmounts := []string{"0.1", "0.5", "1", "2", "5", "10"}
for _, amountStr := range testAmounts {
if amount, err := engine.decimalConverter.FromString(amountStr, 18, "ETH"); err == nil {
amounts = append(amounts, amount)
}
}
return amounts
}
// filterEnabledExchanges filters exchanges based on configuration
func (engine *ArbitrageDetectionEngine) filterEnabledExchanges(exchangeConfigs []*exchanges.ExchangeConfig) []*exchanges.ExchangeConfig {
enabled := make([]*exchanges.ExchangeConfig, 0)
enabledMap := make(map[math.ExchangeType]bool)
for _, exchangeType := range engine.config.EnabledExchanges {
enabledMap[exchangeType] = true
}
for _, exchange := range exchangeConfigs {
if enabledMap[exchange.Type] {
enabled = append(enabled, exchange)
}
}
return enabled
}
// processScanTasksBatch processes scan tasks in batches for efficiency
func (engine *ArbitrageDetectionEngine) processScanTasksBatch(ctx context.Context, tasks []ScanTask) {
batchSize := engine.config.BatchSize
for i := 0; i < len(tasks); i += batchSize {
end := i + batchSize
if end > len(tasks) {
end = len(tasks)
}
batch := tasks[i:end]
engine.processScanBatch(ctx, batch)
// Small delay between batches to avoid overwhelming the system
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Millisecond):
}
}
}
// processScanBatch processes a batch of scan tasks concurrently
func (engine *ArbitrageDetectionEngine) processScanBatch(ctx context.Context, batch []ScanTask) {
resultChans := make([]chan ScanResult, len(batch))
// Submit tasks to worker pool
for i, task := range batch {
resultChans[i] = task.ResultChan
select {
case engine.scanWorkers.taskChan <- task:
case <-ctx.Done():
return
}
}
// Collect results
for _, resultChan := range resultChans {
select {
case result := <-resultChan:
if result.Error != nil {
engine.logger.Debug(fmt.Sprintf("Scan task error: %v", result.Error))
continue
}
if result.Opportunity != nil && engine.calculator.IsOpportunityProfitable(result.Opportunity) {
engine.opportunityCount++
// Send opportunity to processing channel
select {
case engine.opportunityChan <- result.Opportunity:
profitDisplay := ethAmountString(engine.decimalConverter, nil, result.Opportunity.NetProfit)
engine.logger.Info(fmt.Sprintf("🎯 Found profitable arbitrage: %s ETH profit, %0.1f%% confidence",
profitDisplay,
result.Opportunity.Confidence*100))
default:
engine.logger.Warn("Opportunity channel full, dropping opportunity")
}
}
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
engine.logger.Warn("Scan task timed out")
}
}
}
// processScanTask processes a single scan task
func (engine *ArbitrageDetectionEngine) processScanTask(task ScanTask) {
start := time.Now()
// Find arbitrage paths between exchanges
paths := engine.findArbitragePaths(task.TokenPair, task.Exchanges)
var bestOpportunity *types.ArbitrageOpportunity
for _, path := range paths {
// Calculate arbitrage opportunity
opportunity, err := engine.calculator.CalculateArbitrageOpportunity(
path,
task.InputAmount,
math.TokenInfo{
Address: task.TokenPair.Token0.Address,
Symbol: task.TokenPair.Token0.Symbol,
Decimals: task.TokenPair.Token0.Decimals,
},
math.TokenInfo{
Address: task.TokenPair.Token1.Address,
Symbol: task.TokenPair.Token1.Symbol,
Decimals: task.TokenPair.Token1.Decimals,
},
)
if err != nil {
continue
}
// Check if this is the best opportunity so far
if bestOpportunity == nil || engine.isOpportunityBetter(opportunity, bestOpportunity) {
bestOpportunity = opportunity
}
}
result := ScanResult{
Opportunity: bestOpportunity,
ScanTime: time.Since(start),
}
task.ResultChan <- result
}
// findArbitragePaths finds possible arbitrage paths between exchanges
func (engine *ArbitrageDetectionEngine) findArbitragePaths(pair exchanges.TokenPair, exchangeConfigs []*exchanges.ExchangeConfig) [][]*math.PoolData {
paths := make([][]*math.PoolData, 0)
// For simplicity, we'll focus on 2-hop arbitrage (buy on exchange A, sell on exchange B)
// Production implementation would include multi-hop paths
token0Addr := common.HexToAddress(pair.Token0.Address)
token1Addr := common.HexToAddress(pair.Token1.Address)
for i, exchange1 := range exchangeConfigs {
for j, exchange2 := range exchangeConfigs {
if i == j {
continue // Same exchange
}
// Find pools on each exchange
pool1 := engine.findBestPool(exchange1, token0Addr, token1Addr)
pool2 := engine.findBestPool(exchange2, token1Addr, token0Addr) // Reverse direction
if pool1 != nil && pool2 != nil {
path := []*math.PoolData{pool1, pool2}
paths = append(paths, path)
}
}
}
return paths
}
// findBestPool finds the best pool for a token pair on an exchange
func (engine *ArbitrageDetectionEngine) findBestPool(exchange *exchanges.ExchangeConfig, token0, token1 common.Address) *math.PoolData {
// Get the pool detector and liquidity fetcher from the registry
poolDetector := engine.registry.GetPoolDetector(exchange.Type)
liquidityFetcher := engine.registry.GetLiquidityFetcher(exchange.Type)
if poolDetector == nil || liquidityFetcher == nil {
return nil
}
// Get pools for this pair
pools, err := poolDetector.GetAllPools(token0, token1)
if err != nil || len(pools) == 0 {
return nil
}
// For now, return data for the first pool
// Production implementation would compare liquidity and select the best
poolData, err := liquidityFetcher.GetPoolData(pools[0])
if err != nil {
return nil
}
return poolData
}
// isOpportunityBetter compares two opportunities and returns true if the first is better
func (engine *ArbitrageDetectionEngine) isOpportunityBetter(opp1, opp2 *types.ArbitrageOpportunity) bool {
if opp1 == nil {
return false
}
if opp2 == nil {
return true
}
if opp1.Quantities != nil && opp2.Quantities != nil {
net1, err1 := engine.decimalAmountToUniversal(opp1.Quantities.NetProfit)
net2, err2 := engine.decimalAmountToUniversal(opp2.Quantities.NetProfit)
if err1 == nil && err2 == nil {
cmp, err := engine.decimalConverter.Compare(net1, net2)
if err == nil {
if cmp > 0 {
return true
} else if cmp < 0 {
return false
}
}
}
}
// Fallback to canonical big.Int comparison
if opp1.NetProfit != nil && opp2.NetProfit != nil {
if opp1.NetProfit.Cmp(opp2.NetProfit) > 0 {
return true
} else if opp1.NetProfit.Cmp(opp2.NetProfit) < 0 {
return false
}
}
return opp1.Confidence > opp2.Confidence
}
// processPathTask processes a path finding task
func (engine *ArbitrageDetectionEngine) processPathTask(task ScanTask) {
// This would be used for more complex path finding algorithms
// For now, defer to the main scan task processing
engine.processScanTask(task)
}
func (engine *ArbitrageDetectionEngine) decimalAmountToUniversal(dec types.DecimalAmount) (*math.UniversalDecimal, error) {
if dec.Value == "" {
return nil, fmt.Errorf("decimal amount empty")
}
value, ok := new(big.Int).SetString(dec.Value, 10)
if !ok {
return nil, fmt.Errorf("invalid decimal amount %s", dec.Value)
}
return math.NewUniversalDecimal(value, dec.Decimals, dec.Symbol)
}
// opportunityProcessor processes discovered opportunities
func (engine *ArbitrageDetectionEngine) opportunityProcessor(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-engine.stopChan:
return
case opportunity := <-engine.opportunityChan:
engine.processOpportunity(opportunity)
}
}
}
// processOpportunity handles the detailed processing of a discovered arbitrage opportunity.
// This includes logging detailed information about the opportunity, validating its parameters,
// and potentially forwarding it to execution systems.
//
// The processing includes:
// 1. Detailed logging of opportunity parameters
// 2. Validation of profit calculations
// 3. Risk assessment
// 4. Forwarding to registered opportunity handlers
//
// Parameters:
// - opportunity: The arbitrage opportunity to process
func (engine *ArbitrageDetectionEngine) processOpportunity(opportunity *types.ArbitrageOpportunity) {
// Log the opportunity discovery with truncated addresses for readability
engine.logger.Info(fmt.Sprintf("Processing arbitrage opportunity: %s -> %s",
opportunity.TokenIn.Hex()[:8],
opportunity.TokenOut.Hex()[:8]))
if opportunity.Quantities != nil {
if amt, err := engine.decimalAmountToUniversal(opportunity.Quantities.AmountIn); err == nil {
engine.logger.Info(fmt.Sprintf(" Input Amount: %s %s",
engine.decimalConverter.ToHumanReadable(amt), amt.Symbol))
}
} else if opportunity.AmountIn != nil {
amountDisplay := ethAmountString(engine.decimalConverter, nil, opportunity.AmountIn)
engine.logger.Info(fmt.Sprintf(" Input Amount: %s", amountDisplay))
}
engine.logger.Info(fmt.Sprintf(" Input Token: %s",
opportunity.TokenIn.Hex()))
if opportunity.Quantities != nil {
if net, err := engine.decimalAmountToUniversal(opportunity.Quantities.NetProfit); err == nil {
engine.logger.Info(fmt.Sprintf(" Net Profit: %s %s",
engine.decimalConverter.ToHumanReadable(net), net.Symbol))
}
} else if opportunity.NetProfit != nil {
netProfitDisplay := ethAmountString(engine.decimalConverter, nil, opportunity.NetProfit)
engine.logger.Info(fmt.Sprintf(" Net Profit: %s ETH",
netProfitDisplay))
}
engine.logger.Info(fmt.Sprintf(" ROI: %.2f%%", opportunity.ROI))
if opportunity.Quantities != nil {
if impact, err := engine.decimalAmountToUniversal(opportunity.Quantities.PriceImpact); err == nil {
engine.logger.Info(fmt.Sprintf(" Price Impact: %s %s",
engine.decimalConverter.ToHumanReadable(impact), impact.Symbol))
}
} else {
engine.logger.Info(fmt.Sprintf(" Price Impact: %.2f%%", opportunity.PriceImpact))
}
engine.logger.Info(fmt.Sprintf(" Confidence: %.1f%%", opportunity.Confidence*100))
engine.logger.Info(fmt.Sprintf(" Risk Level: %.2f", opportunity.Risk))
engine.logger.Info(fmt.Sprintf(" Protocol: %s", opportunity.Protocol))
engine.logger.Info(fmt.Sprintf(" Path length: %d", len(opportunity.Path)))
if engine.opportunityHandler != nil {
// CRITICAL FIX: Use semaphore for backpressure to prevent OOM
// Bug: Unbounded goroutine creation under high opportunity rate
// Fix: Acquire semaphore before launching handler goroutine
select {
case engine.handlerSemaphore <- struct{}{}:
// Successfully acquired semaphore slot
go func(opp *types.ArbitrageOpportunity) {
defer func() {
<-engine.handlerSemaphore // Release semaphore
}()
engine.opportunityHandler(opp)
}(opportunity)
default:
// All handler slots busy - log and drop opportunity
engine.logger.Warn(fmt.Sprintf("Handler backpressure: dropping opportunity (all %d handlers busy)", engine.maxHandlers))
}
}
}
// SetOpportunityHandler registers a callback function that will be invoked when a profitable
// arbitrage opportunity is discovered. This is the primary integration point between the
// detection engine and execution systems.
//
// The handler function should:
// 1. Perform additional validation if needed
// 2. Make final go/no-go decisions based on current market conditions
// 3. Trigger transaction execution if appropriate
// 4. Handle any execution errors or failures
//
// Note: The handler is called asynchronously to avoid blocking the detection loop.
//
// Parameters:
// - handler: Function to call when opportunities are discovered
func (engine *ArbitrageDetectionEngine) SetOpportunityHandler(handler func(*types.ArbitrageOpportunity)) {
engine.opportunityHandler = handler
}
// GetOpportunityChannel returns the channel for receiving opportunities
func (engine *ArbitrageDetectionEngine) GetOpportunityChannel() <-chan *types.ArbitrageOpportunity {
return engine.opportunityChan
}
// GetStats returns detection engine statistics
func (engine *ArbitrageDetectionEngine) GetStats() DetectionStats {
engine.runningMutex.RLock()
defer engine.runningMutex.RUnlock()
return DetectionStats{
IsRunning: engine.isRunning,
TotalScans: engine.scanCount,
OpportunitiesFound: engine.opportunityCount,
LastScanTime: engine.lastScanTime,
ScanInterval: engine.config.ScanInterval,
ConfiguredExchanges: len(engine.config.EnabledExchanges),
}
}
// ScanOpportunities scans for arbitrage opportunities using the provided parameters
func (engine *ArbitrageDetectionEngine) ScanOpportunities(ctx context.Context, params []*DetectionParams) ([]*types.ArbitrageOpportunity, error) {
if !engine.isRunning {
return nil, fmt.Errorf("detection engine is not running, call Start() first")
}
var opportunities []*types.ArbitrageOpportunity
// Process each detection parameter
for _, param := range params {
paramOpportunities := engine.scanForSingleParam(param)
opportunities = append(opportunities, paramOpportunities...)
}
return opportunities, nil
}
// scanForSingleParam handles scanning for a single detection parameter
func (engine *ArbitrageDetectionEngine) scanForSingleParam(param *DetectionParams) []*types.ArbitrageOpportunity {
tokenPair := engine.createTokenPair(param)
// Get exchange configurations for this token pair
exchangeConfigs := engine.registry.GetExchangesForPair(common.HexToAddress(tokenPair.Token0.Address), common.HexToAddress(tokenPair.Token1.Address))
if len(exchangeConfigs) < 2 {
return nil // Need at least 2 exchanges for arbitrage
}
// Find all possible arbitrage paths between the tokens
paths := engine.findArbitragePaths(tokenPair, exchangeConfigs)
return engine.processPathsForOpportunities(paths, param)
}
// createTokenPair creates a token pair from detection parameters
func (engine *ArbitrageDetectionEngine) createTokenPair(param *DetectionParams) exchanges.TokenPair {
// Create token info using simplified approach for now
// In production, this would query contract metadata
token0Info := exchanges.TokenInfo{
Address: param.TokenA.Hex(),
Symbol: param.TokenA.Hex()[:8], // Use first 8 chars of address as symbol
Name: "Unknown Token",
Decimals: 18, // Standard ERC-20 decimals
}
token1Info := exchanges.TokenInfo{
Address: param.TokenB.Hex(),
Symbol: param.TokenB.Hex()[:8], // Use first 8 chars of address as symbol
Name: "Unknown Token",
Decimals: 18, // Standard ERC-20 decimals
}
return exchanges.TokenPair{
Token0: token0Info,
Token1: token1Info,
}
}
// processPathsForOpportunities processes paths to find profitable opportunities
func (engine *ArbitrageDetectionEngine) processPathsForOpportunities(paths [][]*math.PoolData, param *DetectionParams) []*types.ArbitrageOpportunity {
var opportunities []*types.ArbitrageOpportunity
for _, path := range paths {
if len(path) == 0 {
continue
}
pathOpportunities := engine.evaluatePath(path, param)
opportunities = append(opportunities, pathOpportunities...)
}
return opportunities
}
// evaluatePath evaluates an arbitrage path for opportunities
func (engine *ArbitrageDetectionEngine) evaluatePath(path []*math.PoolData, param *DetectionParams) []*types.ArbitrageOpportunity {
var opportunities []*types.ArbitrageOpportunity
// Get token info for the first and last pools in the path
tokenA := path[0].Token0
tokenZ := path[len(path)-1].Token1
if path[len(path)-1].Token0.Address == tokenA.Address {
tokenZ = path[len(path)-1].Token0
}
// Test various input amounts to find the most profitable one
inputAmounts := engine.getInputAmountsToTest()
for _, inputAmount := range inputAmounts {
// Calculate arbitrage opportunity using the calculator
opportunity, err := engine.calculator.CalculateArbitrageOpportunity(path, inputAmount, tokenA, tokenZ)
if err != nil {
engine.logger.Debug(fmt.Sprintf("Failed to calculate opportunity for path: %v", err))
continue
}
// Apply filters based on the parameters
if opportunity.NetProfit.Cmp(param.MinProfit) < 0 {
continue // Below minimum profit threshold
}
// Check slippage threshold
if opportunity.PriceImpact > param.MaxSlippage {
continue // Above maximum slippage tolerance
}
// Add to opportunities if it passes all checks
opportunities = append(opportunities, opportunity)
// For now, break after finding one good opportunity per path
// to avoid too many similar results (can be made configurable)
break
}
return opportunities
}
// DetectionStats contains statistics about the detection engine
type DetectionStats struct {
IsRunning bool
TotalScans uint64
OpportunitiesFound uint64
LastScanTime time.Time
ScanInterval time.Duration
ConfiguredExchanges int
}
// NewWorkerPool creates a new worker pool
func NewWorkerPool(workers int, ctx context.Context) *WorkerPool {
ctx, cancel := context.WithCancel(ctx)
return &WorkerPool{
workers: workers,
taskChan: make(chan ScanTask, workers*2), // Buffered channel
ctx: ctx,
cancel: cancel,
}
}
// Start starts the worker pool
func (wp *WorkerPool) Start(taskProcessor func(ScanTask)) {
for i := 0; i < wp.workers; i++ {
wp.wg.Add(1)
go func() {
defer wp.wg.Done()
for {
select {
case <-wp.ctx.Done():
return
case task := <-wp.taskChan:
taskProcessor(task)
}
}
}()
}
}
// Stop stops the worker pool
func (wp *WorkerPool) Stop() {
wp.cancel()
close(wp.taskChan)
wp.wg.Wait()
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
package arbitrage
import (
"math/big"
"testing"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/fraktal/mev-beta/bindings/contracts"
"github.com/fraktal/mev-beta/internal/logger"
pkgtypes "github.com/fraktal/mev-beta/pkg/types"
)
type mockArbitrageLogParser struct {
event *contracts.ArbitrageExecutorArbitrageExecuted
err error
}
func (m *mockArbitrageLogParser) ParseArbitrageExecuted(types.Log) (*contracts.ArbitrageExecutorArbitrageExecuted, error) {
if m.err != nil {
return nil, m.err
}
return m.event, nil
}
func newTestLogger() *logger.Logger {
return logger.New("error", "text", "")
}
func TestCalculateActualProfit_UsesArbitrageEvent(t *testing.T) {
arbitrageAddr := common.HexToAddress("0x1234567890123456789012345678901234567890")
executor := NewFlashSwapExecutor(nil, newTestLogger(), nil, nil, common.Address{}, arbitrageAddr, ExecutionConfig{})
profit := new(big.Int).Mul(big.NewInt(2), powerOfTenInt(18))
gasPrice := big.NewInt(1_000_000_000) // 1 gwei
receipt := &types.Receipt{
Logs: []*types.Log{{Address: arbitrageAddr}},
GasUsed: 100000,
EffectiveGasPrice: gasPrice,
}
event := &contracts.ArbitrageExecutorArbitrageExecuted{
Tokens: []common.Address{executor.ethReferenceToken},
Amounts: []*big.Int{profit},
Profit: profit,
}
executor.arbitrageBinding = &mockArbitrageLogParser{event: event}
opportunity := &pkgtypes.ArbitrageOpportunity{
TokenOut: executor.ethReferenceToken,
Quantities: &pkgtypes.OpportunityQuantities{
NetProfit: pkgtypes.DecimalAmount{Symbol: "WETH", Decimals: 18},
},
}
actual, err := executor.calculateActualProfit(receipt, opportunity)
if err != nil {
t.Fatalf("calculateActualProfit returned error: %v", err)
}
gasCost := new(big.Int).Mul(big.NewInt(0).SetUint64(receipt.GasUsed), gasPrice)
expected := new(big.Int).Sub(profit, gasCost)
if actual.Value.Cmp(expected) != 0 {
t.Fatalf("expected profit %s, got %s", expected.String(), actual.Value.String())
}
if actual.Decimals != 18 {
t.Fatalf("expected decimals 18, got %d", actual.Decimals)
}
if actual.Symbol != "WETH" {
t.Fatalf("expected symbol WETH, got %s", actual.Symbol)
}
}
func TestCalculateActualProfit_FallbackToOpportunity(t *testing.T) {
arbitrageAddr := common.HexToAddress("0x1234567890123456789012345678901234567891")
executor := NewFlashSwapExecutor(nil, newTestLogger(), nil, nil, common.Address{}, arbitrageAddr, ExecutionConfig{})
profit := big.NewInt(1_500_000) // 1.5 USDC with 6 decimals
gasPrice := big.NewInt(1_000_000_000) // 1 gwei
receipt := &types.Receipt{
Logs: []*types.Log{{Address: arbitrageAddr}},
GasUsed: 100000,
EffectiveGasPrice: gasPrice,
}
opportunity := &pkgtypes.ArbitrageOpportunity{
TokenOut: common.HexToAddress("0xaF88d065e77c8cC2239327C5EDb3A432268e5831"), // USDC
NetProfit: profit,
Quantities: &pkgtypes.OpportunityQuantities{
NetProfit: pkgtypes.DecimalAmount{Symbol: "USDC", Decimals: 6},
},
}
actual, err := executor.calculateActualProfit(receipt, opportunity)
if err != nil {
t.Fatalf("calculateActualProfit returned error: %v", err)
}
gasCostEth := new(big.Int).Mul(big.NewInt(0).SetUint64(receipt.GasUsed), gasPrice)
// Gas cost conversion: 0.0001 ETH * 2000 USD / 1 USD = 0.2 USDC => 200000 units
gasCostUSDC := big.NewInt(200000)
expected := new(big.Int).Sub(profit, gasCostUSDC)
if actual.Value.Cmp(expected) != 0 {
t.Fatalf("expected profit %s, got %s", expected.String(), actual.Value.String())
}
if actual.Decimals != 6 {
t.Fatalf("expected decimals 6, got %d", actual.Decimals)
}
if actual.Symbol != "USDC" {
t.Fatalf("expected symbol USDC, got %s", actual.Symbol)
}
// Ensure ETH gas cost unchanged for reference
if gasCostEth.Sign() == 0 {
t.Fatalf("expected non-zero gas cost")
}
}
func TestCalculateActualProfit_NoPriceData(t *testing.T) {
arbitrageAddr := common.HexToAddress("0x1234567890123456789012345678901234567892")
executor := NewFlashSwapExecutor(nil, newTestLogger(), nil, nil, common.Address{}, arbitrageAddr, ExecutionConfig{})
profit := new(big.Int).Mul(big.NewInt(3), powerOfTenInt(17)) // 0.3 units with 18 decimals
receipt := &types.Receipt{
Logs: []*types.Log{{Address: arbitrageAddr}},
GasUsed: 50000,
EffectiveGasPrice: big.NewInt(2_000_000_000),
}
unknownToken := common.HexToAddress("0x9b8D58d870495459c1004C34357F3bf06c0dB0b3")
opportunity := &pkgtypes.ArbitrageOpportunity{
TokenOut: unknownToken,
NetProfit: profit,
Quantities: &pkgtypes.OpportunityQuantities{
NetProfit: pkgtypes.DecimalAmount{Symbol: "XYZ", Decimals: 18},
},
}
actual, err := executor.calculateActualProfit(receipt, opportunity)
if err != nil {
t.Fatalf("calculateActualProfit returned error: %v", err)
}
if actual.Value.Cmp(profit) != 0 {
t.Fatalf("expected profit %s, got %s", profit.String(), actual.Value.String())
}
if actual.Symbol != "XYZ" {
t.Fatalf("expected symbol XYZ, got %s", actual.Symbol)
}
}
func TestParseRevertReason_ErrorString(t *testing.T) {
strType, err := abi.NewType("string", "", nil)
if err != nil {
t.Fatalf("failed to create ABI type: %v", err)
}
args := abi.Arguments{{Type: strType}}
payload, err := args.Pack("execution reverted: slippage limit")
if err != nil {
t.Fatalf("failed to pack revert reason: %v", err)
}
data := append([]byte{0x08, 0xc3, 0x79, 0xa0}, payload...)
reason := parseRevertReason(data)
if reason != "execution reverted: slippage limit" {
t.Fatalf("expected revert reason, got %q", reason)
}
}
func TestParseRevertReason_PanicCode(t *testing.T) {
uintType, err := abi.NewType("uint256", "", nil)
if err != nil {
t.Fatalf("failed to create uint256 ABI type: %v", err)
}
args := abi.Arguments{{Type: uintType}}
payload, err := args.Pack(big.NewInt(0x41))
if err != nil {
t.Fatalf("failed to pack panic code: %v", err)
}
data := append([]byte{0x4e, 0x48, 0x7b, 0x71}, payload...)
reason := parseRevertReason(data)
if reason != "panic code 0x41" {
t.Fatalf("expected panic code, got %q", reason)
}
}
func TestParseRevertReason_Unknown(t *testing.T) {
reason := parseRevertReason([]byte{0x00, 0x01, 0x02, 0x03})
if reason != "" {
t.Fatalf("expected empty reason, got %q", reason)
}
}

View File

@@ -0,0 +1,38 @@
package arbitrage
import (
"math/big"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)
type flashSwapCallback struct {
TokenPath []common.Address
PoolPath []common.Address
Fees []*big.Int
MinAmountOut *big.Int
}
func encodeFlashSwapCallback(tokenPath []common.Address, poolPath []common.Address, fees []*big.Int, minAmountOut *big.Int) ([]byte, error) {
tupleType, err := abi.NewType("tuple", "flashSwapCallback", []abi.ArgumentMarshaling{
{Name: "tokenPath", Type: "address[]"},
{Name: "poolPath", Type: "address[]"},
{Name: "fees", Type: "uint256[]"},
{Name: "minAmountOut", Type: "uint256"},
})
if err != nil {
return nil, err
}
arguments := abi.Arguments{{Type: tupleType}}
callback := flashSwapCallback{
TokenPath: tokenPath,
PoolPath: poolPath,
Fees: fees,
MinAmountOut: minAmountOut,
}
return arguments.Pack(callback)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
package arbitrage
import (
"math/big"
"testing"
"time"
"github.com/fraktal/mev-beta/internal/logger"
"github.com/fraktal/mev-beta/pkg/math"
pkgtypes "github.com/fraktal/mev-beta/pkg/types"
)
func TestPerformRiskChecksRespectsDailyLossLimitDecimals(t *testing.T) {
log := logger.New("debug", "text", "")
dc := math.NewDecimalConverter()
maxPos, _ := dc.FromString("10", 18, "ETH")
dailyLimit, _ := dc.FromString("0.5", 18, "ETH")
profitTarget, _ := dc.FromString("1", 18, "ETH")
zero, _ := dc.FromString("0", 18, "ETH")
framework := &LiveExecutionFramework{
logger: log,
decimalConverter: dc,
config: FrameworkConfig{
MaxPositionSize: maxPos,
DailyLossLimit: dailyLimit,
DailyProfitTarget: profitTarget,
},
stats: &FrameworkStats{
DailyStats: make(map[string]*DailyStats),
},
}
today := time.Now().Format("2006-01-02")
framework.stats.DailyStats[today] = &DailyStats{
Date: today,
ProfitRealized: zero.Copy(),
GasCostPaid: zero.Copy(),
}
loss, _ := math.NewUniversalDecimal(big.NewInt(-600000000000000000), 18, "ETH")
framework.stats.DailyStats[today].NetProfit = loss
opportunity := &pkgtypes.ArbitrageOpportunity{
AmountIn: big.NewInt(1000000000000000000),
Confidence: 0.95,
}
if framework.performRiskChecks(opportunity) {
t.Fatalf("expected opportunity to be rejected when loss exceeds limit")
}
acceptableLoss, _ := math.NewUniversalDecimal(big.NewInt(-200000000000000000), 18, "ETH")
framework.stats.DailyStats[today].NetProfit = acceptableLoss
if !framework.performRiskChecks(opportunity) {
t.Fatalf("expected opportunity to pass when loss within limit")
}
}
func TestOpportunityHelpersHandleMissingQuantities(t *testing.T) {
dc := math.NewDecimalConverter()
profit := big.NewInt(2100000000000000)
opportunity := &pkgtypes.ArbitrageOpportunity{
NetProfit: profit,
}
expectedDecimal := universalFromWei(dc, profit, "ETH")
resultDecimal := opportunityNetProfitDecimal(dc, opportunity)
if cmp, err := dc.Compare(resultDecimal, expectedDecimal); err != nil || cmp != 0 {
t.Fatalf("expected fallback decimal to match wei amount, got %s", dc.ToHumanReadable(resultDecimal))
}
expectedString := ethAmountString(dc, expectedDecimal, nil)
resultString := opportunityAmountString(dc, opportunity)
if resultString != expectedString {
t.Fatalf("expected fallback string %s, got %s", expectedString, resultString)
}
}
func TestOpportunityHelpersPreferDecimalSnapshots(t *testing.T) {
dc := math.NewDecimalConverter()
declared, _ := math.NewUniversalDecimal(big.NewInt(1500000000000000000), 18, "ETH")
opportunity := &pkgtypes.ArbitrageOpportunity{
NetProfit: new(big.Int).Set(declared.Value),
Quantities: &pkgtypes.OpportunityQuantities{
NetProfit: pkgtypes.DecimalAmount{
Value: declared.Value.String(),
Decimals: declared.Decimals,
Symbol: declared.Symbol,
},
},
}
resultDecimal := opportunityNetProfitDecimal(dc, opportunity)
if cmp, err := dc.Compare(resultDecimal, declared); err != nil || cmp != 0 {
t.Fatalf("expected decimal snapshot to be used, got %s", dc.ToHumanReadable(resultDecimal))
}
expectedString := ethAmountString(dc, declared, nil)
resultString := opportunityAmountString(dc, opportunity)
if resultString != expectedString {
t.Fatalf("expected human-readable snapshot %s, got %s", expectedString, resultString)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,436 @@
package arbitrage
import (
"context"
"math/big"
"testing"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/holiman/uint256"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/fraktal/mev-beta/internal/logger"
"github.com/fraktal/mev-beta/pkg/market"
)
// MockMarketManager is a mock implementation of MarketManager for testing
type MockMarketManager struct {
mock.Mock
}
func (m *MockMarketManager) GetAllPools() []market.PoolData {
args := m.Called()
return args.Get(0).([]market.PoolData)
}
func (m *MockMarketManager) GetPool(ctx context.Context, poolAddress common.Address) (*market.PoolData, error) {
args := m.Called(ctx, poolAddress)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*market.PoolData), args.Error(1)
}
func (m *MockMarketManager) GetPoolsByTokens(token0, token1 common.Address) []*market.PoolData {
args := m.Called(token0, token1)
return args.Get(0).([]*market.PoolData)
}
func (m *MockMarketManager) UpdatePool(poolAddress common.Address, liquidity *uint256.Int, sqrtPriceX96 *uint256.Int, tick int) {
m.Called(poolAddress, liquidity, sqrtPriceX96, tick)
}
func (m *MockMarketManager) GetPoolsByTokensWithProtocol(token0, token1 common.Address, protocol string) []*market.PoolData {
args := m.Called(token0, token1, protocol)
return args.Get(0).([]*market.PoolData)
}
// TestNewMultiHopScanner tests the creation of a new MultiHopScanner
func TestNewMultiHopScanner(t *testing.T) {
log := logger.New("info", "text", "")
marketMgr := &market.MarketManager{}
scanner := NewMultiHopScanner(log, nil, marketMgr)
assert.NotNil(t, scanner)
assert.Equal(t, log, scanner.logger)
// Note: marketMgr is not stored in the scanner struct
// NOTE: These values have been optimized for aggressive opportunity detection:
// - maxHops reduced from 4 to 3 for faster execution
// - minProfitWei reduced to 0.00001 ETH for more opportunities
// - maxSlippage increased to 5% for broader market coverage
// - maxPaths increased to 200 for thorough opportunity search
// - pathTimeout increased to 2s for complete analysis
assert.Equal(t, 3, scanner.maxHops)
assert.Equal(t, "10000000000000", scanner.minProfitWei.String())
assert.Equal(t, 0.05, scanner.maxSlippage)
assert.Equal(t, 200, scanner.maxPaths)
assert.Equal(t, time.Second*2, scanner.pathTimeout)
assert.NotNil(t, scanner.pathCache)
assert.NotNil(t, scanner.tokenGraph)
assert.NotNil(t, scanner.pools)
}
// TestTokenGraph tests the TokenGraph functionality
func TestTokenGraph(t *testing.T) {
graph := NewTokenGraph()
assert.NotNil(t, graph)
assert.NotNil(t, graph.adjacencyList)
// Test adding edges
tokenA := common.HexToAddress("0xA")
tokenB := common.HexToAddress("0xB")
sqrtPriceX96, _ := uint256.FromDecimal("79228162514264337593543950336")
pool := &PoolInfo{
Address: common.HexToAddress("0x1"),
Token0: tokenA,
Token1: tokenB,
Protocol: "UniswapV3",
Fee: 3000,
Liquidity: uint256.NewInt(1000000),
SqrtPriceX96: sqrtPriceX96,
LastUpdated: time.Now(),
}
// Add pool to graph
graph.mutex.Lock()
graph.adjacencyList[tokenA] = make(map[common.Address][]*PoolInfo)
graph.adjacencyList[tokenA][tokenB] = append(graph.adjacencyList[tokenA][tokenB], pool)
graph.mutex.Unlock()
// Test getting adjacent tokens
adjacent := graph.GetAdjacentTokens(tokenA)
assert.Len(t, adjacent, 1)
assert.Contains(t, adjacent, tokenB)
assert.Len(t, adjacent[tokenB], 1)
assert.Equal(t, pool, adjacent[tokenB][0])
}
// TestIsPoolUsable tests the isPoolUsable function
func TestIsPoolUsable(t *testing.T) {
log := logger.New("info", "text", "")
marketMgr := &market.MarketManager{}
scanner := NewMultiHopScanner(log, nil, marketMgr)
// Test usable pool (recent and sufficient liquidity)
now := time.Now()
sqrtPriceX961, _ := uint256.FromDecimal("79228162514264337593543950336")
usablePool := &PoolInfo{
Address: common.HexToAddress("0x1"),
Token0: common.HexToAddress("0xA"),
Token1: common.HexToAddress("0xB"),
Protocol: "UniswapV3",
Fee: 3000,
Liquidity: uint256.NewInt(1000000000000000000), // 1 ETH worth of liquidity
SqrtPriceX96: sqrtPriceX961,
LastUpdated: now,
}
assert.True(t, scanner.isPoolUsable(usablePool))
// Test pool with insufficient liquidity
sqrtPriceX962, _ := uint256.FromDecimal("79228162514264337593543950336")
unusablePool1 := &PoolInfo{
Address: common.HexToAddress("0x2"),
Token0: common.HexToAddress("0xA"),
Token1: common.HexToAddress("0xB"),
Protocol: "UniswapV3",
Fee: 3000,
Liquidity: uint256.NewInt(10000000000000000), // 0.01 ETH worth of liquidity (too little)
SqrtPriceX96: sqrtPriceX962,
LastUpdated: now,
}
assert.False(t, scanner.isPoolUsable(unusablePool1))
// Test stale pool
sqrtPriceX963, _ := uint256.FromDecimal("79228162514264337593543950336")
stalePool := &PoolInfo{
Address: common.HexToAddress("0x3"),
Token0: common.HexToAddress("0xA"),
Token1: common.HexToAddress("0xB"),
Protocol: "UniswapV3",
Fee: 3000,
Liquidity: uint256.NewInt(1000000000000000000),
SqrtPriceX96: sqrtPriceX963,
LastUpdated: now.Add(-10 * time.Minute), // 10 minutes ago (stale)
}
assert.False(t, scanner.isPoolUsable(stalePool))
}
// TestCalculateSimpleAMMOutput tests the calculateSimpleAMMOutput function
func TestCalculateSimpleAMMOutput(t *testing.T) {
log := logger.New("info", "text", "")
marketMgr := &market.MarketManager{}
scanner := NewMultiHopScanner(log, nil, marketMgr)
// Create a pool with known values for testing
tokenIn := common.HexToAddress("0xA")
tokenOut := common.HexToAddress("0xB")
// Create a pool with realistic values
// SqrtPriceX96 = 79228162514264337593543950336 (represents 1.0 price)
// Liquidity = 1000000000000000000 (1 ETH)
sqrtPriceX965, _ := uint256.FromDecimal("79228162514264337593543950336")
pool := &PoolInfo{
Address: common.HexToAddress("0x1"),
Token0: tokenIn,
Token1: tokenOut,
Protocol: "UniswapV2",
Fee: 3000,
Liquidity: uint256.NewInt(1000000000000000000),
SqrtPriceX96: sqrtPriceX965,
LastUpdated: time.Now(),
}
// Test with 1 ETH input
amountIn := big.NewInt(1000000000000000000) // 1 ETH
output, err := scanner.calculateSimpleAMMOutput(amountIn, pool, tokenIn, tokenOut)
// We should get a valid output
assert.NoError(t, err)
assert.NotNil(t, output)
assert.True(t, output.Sign() > 0)
// Test with missing data
badPool := &PoolInfo{
Address: common.HexToAddress("0x2"),
Token0: tokenIn,
Token1: tokenOut,
Protocol: "UniswapV2",
Fee: 3000,
Liquidity: nil, // Missing liquidity
SqrtPriceX96: nil, // Missing sqrtPriceX96
LastUpdated: time.Now(),
}
output, err = scanner.calculateSimpleAMMOutput(amountIn, badPool, tokenIn, tokenOut)
assert.Error(t, err)
assert.Nil(t, output)
}
// TestCalculateUniswapV3Output tests the calculateUniswapV3Output function
func TestCalculateUniswapV3Output(t *testing.T) {
log := logger.New("info", "text", "")
marketMgr := &market.MarketManager{}
scanner := NewMultiHopScanner(log, nil, marketMgr)
// Create a pool with known values for testing
tokenIn := common.HexToAddress("0xA")
tokenOut := common.HexToAddress("0xB")
// Create a pool with realistic values
sqrtPriceX96, _ := uint256.FromDecimal("79228162514264337593543950336")
pool := &PoolInfo{
Address: common.HexToAddress("0x1"),
Token0: tokenIn,
Token1: tokenOut,
Protocol: "UniswapV3",
Fee: 3000,
Liquidity: uint256.NewInt(1000000000000000000),
SqrtPriceX96: sqrtPriceX96,
LastUpdated: time.Now(),
}
// Test with 1 ETH input
amountIn := big.NewInt(1000000000000000000) // 1 ETH
output, err := scanner.calculateUniswapV3Output(amountIn, pool, tokenIn, tokenOut)
// We should get a valid output
assert.NoError(t, err)
assert.NotNil(t, output)
assert.True(t, output.Sign() > 0)
}
// TestEstimateHopGasCost tests the estimateHopGasCost function
func TestEstimateHopGasCost(t *testing.T) {
log := logger.New("info", "text", "")
marketMgr := &market.MarketManager{}
scanner := NewMultiHopScanner(log, nil, marketMgr)
// NOTE: Gas estimates have been optimized for flash loan execution:
// Flash loans are more efficient than capital-requiring swaps because:
// - No capital lock-up required
// - Lower slippage on large amounts
// - More predictable execution
// Therefore, gas costs are realistically lower than non-flash-loan swaps
// Test UniswapV3 - optimized to 70k for flash loans
gas := scanner.estimateHopGasCost("UniswapV3")
assert.Equal(t, int64(70000), gas.Int64())
// Test UniswapV2 - optimized to 60k for flash loans
gas = scanner.estimateHopGasCost("UniswapV2")
assert.Equal(t, int64(60000), gas.Int64())
// Test SushiSwap - optimized to 60k for flash loans (similar to V2)
gas = scanner.estimateHopGasCost("SushiSwap")
assert.Equal(t, int64(60000), gas.Int64())
// Test default case - conservative estimate of 70k
gas = scanner.estimateHopGasCost("UnknownProtocol")
assert.Equal(t, int64(70000), gas.Int64())
}
// TestIsProfitable tests the isProfitable function
func TestIsProfitable(t *testing.T) {
log := logger.New("info", "text", "")
marketMgr := &market.MarketManager{}
scanner := NewMultiHopScanner(log, nil, marketMgr)
// Create a profitable path
profitablePath := &ArbitragePath{
NetProfit: big.NewInt(2000000000000000000), // 2 ETH profit
ROI: 5.0, // 5% ROI
}
assert.True(t, scanner.isProfitable(profitablePath))
// Create an unprofitable path (below minimum profit)
unprofitablePath1 := &ArbitragePath{
NetProfit: big.NewInt(100000000000000000), // 0.1 ETH profit (below 0.001 ETH threshold)
ROI: 0.5, // 0.5% ROI
}
assert.False(t, scanner.isProfitable(unprofitablePath1))
// Create a path with good profit but poor ROI
unprofitablePath2 := &ArbitragePath{
NetProfit: big.NewInt(5000000000000000000), // 5 ETH profit
ROI: 0.5, // 0.5% ROI (below 1% threshold)
}
assert.False(t, scanner.isProfitable(unprofitablePath2))
}
// TestCreateArbitragePath tests the createArbitragePath function
func TestCreateArbitragePath(t *testing.T) {
log := logger.New("info", "text", "")
marketMgr := &market.MarketManager{}
scanner := NewMultiHopScanner(log, nil, marketMgr)
// Test with invalid inputs
tokens := []common.Address{
common.HexToAddress("0xA"),
common.HexToAddress("0xB"),
}
sqrtPriceX966, _ := uint256.FromDecimal("79228162514264337593543950336")
pools := []*PoolInfo{
{
Address: common.HexToAddress("0x1"),
Token0: common.HexToAddress("0xA"),
Token1: common.HexToAddress("0xB"),
Protocol: "UniswapV3",
Fee: 3000,
Liquidity: uint256.NewInt(1000000000000000000),
SqrtPriceX96: sqrtPriceX966,
LastUpdated: time.Now(),
},
}
initialAmount := big.NewInt(1000000000000000000) // 1 ETH
// This should fail because we need at least 3 tokens for a valid arbitrage path (A->B->A)
path := scanner.createArbitragePath(tokens, pools, initialAmount)
assert.Nil(t, path)
// Test with valid inputs (triangle: A->B->C->A)
validTokens := []common.Address{
common.HexToAddress("0xA"),
common.HexToAddress("0xB"),
common.HexToAddress("0xC"),
common.HexToAddress("0xA"), // Back to start
}
sqrtPriceX967, _ := uint256.FromDecimal("79228162514264337593543950336")
sqrtPriceX968, _ := uint256.FromDecimal("79228162514264337593543950336")
sqrtPriceX969, _ := uint256.FromDecimal("79228162514264337593543950336")
validPools := []*PoolInfo{
{
Address: common.HexToAddress("0x1"),
Token0: common.HexToAddress("0xA"),
Token1: common.HexToAddress("0xB"),
Protocol: "UniswapV3",
Fee: 3000,
Liquidity: uint256.NewInt(1000000000000000000),
SqrtPriceX96: sqrtPriceX967,
LastUpdated: time.Now(),
},
{
Address: common.HexToAddress("0x2"),
Token0: common.HexToAddress("0xB"),
Token1: common.HexToAddress("0xC"),
Protocol: "UniswapV3",
Fee: 3000,
Liquidity: uint256.NewInt(1000000000000000000),
SqrtPriceX96: sqrtPriceX968,
LastUpdated: time.Now(),
},
{
Address: common.HexToAddress("0x3"),
Token0: common.HexToAddress("0xC"),
Token1: common.HexToAddress("0xA"),
Protocol: "UniswapV3",
Fee: 3000,
Liquidity: uint256.NewInt(1000000000000000000),
SqrtPriceX96: sqrtPriceX969,
LastUpdated: time.Now(),
},
}
path = scanner.createArbitragePath(validTokens, validPools, initialAmount)
assert.NotNil(t, path)
assert.Len(t, path.Tokens, 4)
assert.Len(t, path.Pools, 3)
assert.Len(t, path.Protocols, 3)
assert.Len(t, path.Fees, 3)
assert.NotNil(t, path.EstimatedGas)
assert.NotNil(t, path.NetProfit)
}
// TestScanForArbitrage tests the main ScanForArbitrage function
func TestScanForArbitrage(t *testing.T) {
log := logger.New("info", "text", "")
// Create a mock market manager
mockMarketMgr := &MockMarketManager{}
sqrtPriceX9610, _ := uint256.FromDecimal("79228162514264337593543950336")
// Set up mock expectations
mockMarketMgr.On("GetAllPools").Return([]market.PoolData{
{
Address: common.HexToAddress("0x1"),
Token0: common.HexToAddress("0xA"),
Token1: common.HexToAddress("0xB"),
Fee: 3000,
Liquidity: uint256.NewInt(1000000000000000000),
SqrtPriceX96: sqrtPriceX9610,
LastUpdated: time.Now(),
},
})
// Skip this test due to deadlock issues in token graph update
t.Skip("TestScanForArbitrage skipped: deadlock in updateTokenGraph with RWMutex")
scanner := NewMultiHopScanner(log, nil, mockMarketMgr)
// Use a context with timeout to prevent the test from hanging
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
triggerToken := common.HexToAddress("0xA")
amount := big.NewInt(1000000000000000000) // 1 ETH
paths, err := scanner.ScanForArbitrage(ctx, triggerToken, amount)
// For now, we expect it to return without error, even if no profitable paths are found
assert.NoError(t, err)
// It's perfectly valid for ScanForArbitrage to return nil or an empty slice when no arbitrage opportunities exist
// The important thing is that it doesn't return an error
// We're not asserting anything about the paths value since nil is acceptable in this case
_ = paths // explicitly ignore paths to avoid 'declared and not used' error
}

View File

@@ -0,0 +1,150 @@
package arbitrage
import (
"context"
"fmt"
"sync"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
)
// NonceManager provides thread-safe nonce management for transaction submission
// Prevents nonce collisions when submitting multiple transactions rapidly
type NonceManager struct {
mu sync.Mutex
client *ethclient.Client
account common.Address
// Track the last nonce we've assigned
lastNonce uint64
// Track pending nonces to avoid reuse
pending map[uint64]bool
// Initialized flag
initialized bool
}
// NewNonceManager creates a new nonce manager for the given account
func NewNonceManager(client *ethclient.Client, account common.Address) *NonceManager {
return &NonceManager{
client: client,
account: account,
pending: make(map[uint64]bool),
initialized: false,
}
}
// GetNextNonce returns the next available nonce for transaction submission
// This method is thread-safe and prevents nonce collisions
func (nm *NonceManager) GetNextNonce(ctx context.Context) (uint64, error) {
nm.mu.Lock()
defer nm.mu.Unlock()
// Get current pending nonce from network
currentNonce, err := nm.client.PendingNonceAt(ctx, nm.account)
if err != nil {
return 0, fmt.Errorf("failed to get pending nonce: %w", err)
}
// First time initialization
if !nm.initialized {
// On first call, hand back the network's pending nonce so we don't
// skip a slot and create gaps that block execution.
nm.lastNonce = currentNonce
nm.initialized = true
nm.pending[currentNonce] = true
return currentNonce, nil
}
// Determine next nonce to use
var nextNonce uint64
// If network nonce is higher than our last assigned, use network nonce
// This handles cases where transactions confirmed between calls
if currentNonce > nm.lastNonce {
nextNonce = currentNonce
nm.lastNonce = currentNonce
// Clear pending nonces below current (they've been mined)
nm.clearPendingBefore(currentNonce)
} else {
// Otherwise increment our last nonce
nextNonce = nm.lastNonce + 1
nm.lastNonce = nextNonce
}
// Mark this nonce as pending
nm.pending[nextNonce] = true
return nextNonce, nil
}
// MarkConfirmed marks a nonce as confirmed (mined in a block)
// This allows the nonce manager to clean up its pending tracking
func (nm *NonceManager) MarkConfirmed(nonce uint64) {
nm.mu.Lock()
defer nm.mu.Unlock()
delete(nm.pending, nonce)
}
// MarkFailed marks a nonce as failed (transaction rejected)
// This allows the nonce to be potentially reused
func (nm *NonceManager) MarkFailed(nonce uint64) {
nm.mu.Lock()
defer nm.mu.Unlock()
delete(nm.pending, nonce)
// Reset lastNonce if this was the last one we assigned
if nonce == nm.lastNonce && nonce > 0 {
nm.lastNonce = nonce - 1
}
}
// GetPendingCount returns the number of pending nonces
func (nm *NonceManager) GetPendingCount() int {
nm.mu.Lock()
defer nm.mu.Unlock()
return len(nm.pending)
}
// Reset resets the nonce manager state
// Should be called if you want to re-sync with network state
func (nm *NonceManager) Reset() {
nm.mu.Lock()
defer nm.mu.Unlock()
nm.pending = make(map[uint64]bool)
nm.initialized = false
nm.lastNonce = 0
}
// clearPendingBefore removes pending nonces below the given threshold
// (internal method, mutex must be held by caller)
func (nm *NonceManager) clearPendingBefore(threshold uint64) {
for nonce := range nm.pending {
if nonce < threshold {
delete(nm.pending, nonce)
}
}
}
// GetCurrentNonce returns the last assigned nonce without incrementing
func (nm *NonceManager) GetCurrentNonce() uint64 {
nm.mu.Lock()
defer nm.mu.Unlock()
return nm.lastNonce
}
// IsPending checks if a nonce is currently pending
func (nm *NonceManager) IsPending(nonce uint64) bool {
nm.mu.Lock()
defer nm.mu.Unlock()
return nm.pending[nonce]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
package arbitrage
import (
"testing"
)
func TestParseSignedInt256(t *testing.T) {
sas := &ArbitrageService{}
tests := []struct {
name string
input []byte
expected string
}{
{
name: "positive value",
input: make([]byte, 32), // All zeros = 0
expected: "0",
},
{
name: "negative value",
input: func() []byte {
// Create a -1 value in two's complement (all 1s)
data := make([]byte, 32)
for i := range data {
data[i] = 0xFF
}
return data
}(),
expected: "-1",
},
{
name: "large positive value",
input: func() []byte {
data := make([]byte, 32)
data[31] = 0x01 // Small positive number
return data
}(),
expected: "1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := sas.parseSignedInt256(tt.input)
if err != nil {
t.Fatalf("parseSignedInt256() error = %v", err)
}
if result.String() != tt.expected {
t.Errorf("parseSignedInt256() = %v, want %v", result.String(), tt.expected)
}
})
}
}
func TestParseSignedInt24(t *testing.T) {
sas := &ArbitrageService{}
tests := []struct {
name string
input []byte
expected int32
}{
{
name: "zero value",
input: make([]byte, 32), // All zeros
expected: 0,
},
{
name: "positive value",
input: func() []byte {
data := make([]byte, 32)
data[31] = 0x01 // 1
return data
}(),
expected: 1,
},
{
name: "negative value (-1)",
input: func() []byte {
data := make([]byte, 32)
data[28] = 0xFF
// Set the 24-bit value to all 1s (which is -1 in two's complement)
data[29] = 0xFF
data[30] = 0xFF
data[31] = 0xFF
return data
}(),
expected: -1,
},
{
name: "max positive int24",
input: func() []byte {
data := make([]byte, 32)
// 0x7FFFFF = 8388607 (max int24)
data[29] = 0x7F
data[30] = 0xFF
data[31] = 0xFF
return data
}(),
expected: 8388607,
},
{
name: "min negative int24",
input: func() []byte {
data := make([]byte, 32)
data[28] = 0xFF
// 0x800000 = -8388608 (min int24)
data[29] = 0x80
data[30] = 0x00
data[31] = 0x00
return data
}(),
expected: -8388608,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := sas.parseSignedInt24(tt.input)
if err != nil {
t.Fatalf("parseSignedInt24() error = %v", err)
}
if result != tt.expected {
t.Errorf("parseSignedInt24() = %v, want %v", result, tt.expected)
}
})
}
}
func TestParseSignedInt24Errors(t *testing.T) {
sas := &ArbitrageService{}
tests := []struct {
name string
input []byte
}{
{
name: "invalid length",
input: make([]byte, 16), // Wrong length
},
{
name: "out of range positive",
input: func() []byte {
data := make([]byte, 32)
// Set a value > 8388607
data[28] = 0x01 // This makes it > 24 bits
return data
}(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := sas.parseSignedInt24(tt.input)
if err == nil {
t.Errorf("parseSignedInt24() expected error but got none")
}
})
}
}

View File

@@ -0,0 +1,51 @@
package arbitrage
import (
"math/big"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/fraktal/mev-beta/pkg/math"
"github.com/fraktal/mev-beta/pkg/mev"
"github.com/fraktal/mev-beta/pkg/types"
)
// ExecutionResult represents the result of an arbitrage execution
type ExecutionResult struct {
TransactionHash common.Hash
GasUsed uint64
GasPrice *big.Int
GasCost *math.UniversalDecimal // Changed to UniversalDecimal for consistency with other components
ProfitRealized *big.Int
Success bool
Error error
ExecutionTime time.Duration
Path *ArbitragePath
ErrorMessage string // Added for compatibility
Status string // Added for compatibility
}
// ExecutionTask represents a task for execution
type ExecutionTask struct {
Opportunity *types.ArbitrageOpportunity
Priority int
Deadline time.Time
SubmissionTime time.Time // Added for tracking when the task was submitted
CompetitionAnalysis *mev.CompetitionData // Changed to match what the analyzer returns
ResultChan chan *ExecutionResult // Channel to send execution result back
}
// DetectionParams represents parameters for arbitrage opportunity detection
type DetectionParams struct {
TokenA common.Address
TokenB common.Address
MinProfit *big.Int
MaxSlippage float64
}
// TokenPair represents a trading pair
type TokenPair struct {
TokenA common.Address
TokenB common.Address
}