diff --git a/pkg/parsers/arbiscan_validator.go b/pkg/parsers/arbiscan_validator.go new file mode 100644 index 0000000..af50d68 --- /dev/null +++ b/pkg/parsers/arbiscan_validator.go @@ -0,0 +1,324 @@ +package parsers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/your-org/mev-bot/pkg/types" +) + +// ArbiscanValidator validates parsed swap events against Arbiscan API +type ArbiscanValidator struct { + apiKey string + baseURL string + httpClient *http.Client + logger types.Logger + swapLogger *SwapLogger +} + +// ArbiscanLog represents a log entry from Arbiscan API +type ArbiscanLog struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` + BlockNumber string `json:"blockNumber"` + TimeStamp string `json:"timeStamp"` + GasPrice string `json:"gasPrice"` + GasUsed string `json:"gasUsed"` + LogIndex string `json:"logIndex"` + TransactionHash string `json:"transactionHash"` + TransactionIndex string `json:"transactionIndex"` +} + +// ArbiscanAPIResponse represents the API response from Arbiscan +type ArbiscanAPIResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Result []ArbiscanLog `json:"result"` +} + +// ValidationResult contains the result of validating a swap event +type ValidationResult struct { + IsValid bool `json:"is_valid"` + SwapEvent *types.SwapEvent `json:"swap_event"` + ArbiscanLog *ArbiscanLog `json:"arbiscan_log"` + Discrepancies []string `json:"discrepancies,omitempty"` + ValidatedAt time.Time `json:"validated_at"` +} + +// NewArbiscanValidator creates a new Arbiscan validator +func NewArbiscanValidator(apiKey string, logger types.Logger, swapLogger *SwapLogger) *ArbiscanValidator { + return &ArbiscanValidator{ + apiKey: apiKey, + baseURL: "https://api.arbiscan.io/api", + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + logger: logger, + swapLogger: swapLogger, + } +} + +// ValidateSwap validates a parsed swap event against Arbiscan +func (v *ArbiscanValidator) ValidateSwap(ctx context.Context, event *types.SwapEvent) (*ValidationResult, error) { + // Fetch transaction logs from Arbiscan + logs, err := v.getTransactionLogs(ctx, event.TxHash) + if err != nil { + return nil, fmt.Errorf("failed to fetch logs from Arbiscan: %w", err) + } + + // Find the matching log by log index + var matchingLog *ArbiscanLog + for i := range logs { + logIndex := parseHexToUint64(logs[i].LogIndex) + if logIndex == uint64(event.LogIndex) { + matchingLog = &logs[i] + break + } + } + + if matchingLog == nil { + return &ValidationResult{ + IsValid: false, + SwapEvent: event, + Discrepancies: []string{fmt.Sprintf("log index %d not found in Arbiscan response", event.LogIndex)}, + ValidatedAt: time.Now(), + }, nil + } + + // Compare parsed event with Arbiscan data + discrepancies := v.compareSwapWithArbiscan(event, matchingLog) + + result := &ValidationResult{ + IsValid: len(discrepancies) == 0, + SwapEvent: event, + ArbiscanLog: matchingLog, + Discrepancies: discrepancies, + ValidatedAt: time.Time(), + } + + // Log validation result + if len(discrepancies) > 0 { + v.logger.Warn("swap validation discrepancies found", + "txHash", event.TxHash.Hex(), + "logIndex", event.LogIndex, + "discrepancies", strings.Join(discrepancies, "; "), + ) + + // Save discrepancy to swap logger for investigation + if v.swapLogger != nil { + rawData := common.Hex2Bytes(strings.TrimPrefix(matchingLog.Data, "0x")) + if err := v.swapLogger.LogSwap(ctx, event, rawData, matchingLog.Topics, "arbiscan_validation"); err != nil { + v.logger.Error("failed to log discrepancy", "error", err) + } + } + } else { + v.logger.Debug("swap validation successful", + "txHash", event.TxHash.Hex(), + "logIndex", event.LogIndex, + ) + } + + return result, nil +} + +// getTransactionLogs fetches transaction logs from Arbiscan API +func (v *ArbiscanValidator) getTransactionLogs(ctx context.Context, txHash common.Hash) ([]ArbiscanLog, error) { + // Build API URL + params := url.Values{} + params.Set("module", "logs") + params.Set("action", "getLogs") + params.Set("txhash", txHash.Hex()) + params.Set("apikey", v.apiKey) + + apiURL := fmt.Sprintf("%s?%s", v.baseURL, params.Encode()) + + // Create request + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Execute request + resp, err := v.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse response + var apiResp ArbiscanAPIResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if apiResp.Status != "1" { + return nil, fmt.Errorf("Arbiscan API error: %s", apiResp.Message) + } + + return apiResp.Result, nil +} + +// compareSwapWithArbiscan compares a parsed swap event with Arbiscan log data +func (v *ArbiscanValidator) compareSwapWithArbiscan(event *types.SwapEvent, log *ArbiscanLog) []string { + var discrepancies []string + + // Compare pool address + if !strings.EqualFold(event.PoolAddress.Hex(), log.Address) { + discrepancies = append(discrepancies, fmt.Sprintf( + "pool address mismatch: parsed=%s arbiscan=%s", + event.PoolAddress.Hex(), log.Address, + )) + } + + // Compare transaction hash + if !strings.EqualFold(event.TxHash.Hex(), log.TransactionHash) { + discrepancies = append(discrepancies, fmt.Sprintf( + "tx hash mismatch: parsed=%s arbiscan=%s", + event.TxHash.Hex(), log.TransactionHash, + )) + } + + // Compare block number + arbiscanBlockNumber := parseHexToUint64(log.BlockNumber) + if event.BlockNumber != arbiscanBlockNumber { + discrepancies = append(discrepancies, fmt.Sprintf( + "block number mismatch: parsed=%d arbiscan=%d", + event.BlockNumber, arbiscanBlockNumber, + )) + } + + // Compare log index + arbiscanLogIndex := parseHexToUint64(log.LogIndex) + if uint64(event.LogIndex) != arbiscanLogIndex { + discrepancies = append(discrepancies, fmt.Sprintf( + "log index mismatch: parsed=%d arbiscan=%d", + event.LogIndex, arbiscanLogIndex, + )) + } + + // Compare topics (event signature and indexed parameters) + if len(log.Topics) > 0 { + expectedSignature := log.Topics[0] + // UniswapV2 Swap signature + if strings.EqualFold(expectedSignature, SwapEventSignature.Hex()) { + // Validate sender and recipient from topics + if len(log.Topics) >= 3 { + arbiscanSender := common.HexToAddress(log.Topics[1]) + arbiscanRecipient := common.HexToAddress(log.Topics[2]) + + if event.Sender != arbiscanSender { + discrepancies = append(discrepancies, fmt.Sprintf( + "sender mismatch: parsed=%s arbiscan=%s", + event.Sender.Hex(), arbiscanSender.Hex(), + )) + } + + if event.Recipient != arbiscanRecipient { + discrepancies = append(discrepancies, fmt.Sprintf( + "recipient mismatch: parsed=%s arbiscan=%s", + event.Recipient.Hex(), arbiscanRecipient.Hex(), + )) + } + } + } + } + + // Note: We don't compare amounts directly here because they're scaled + // and Arbiscan returns raw values. The caller should handle amount validation + // by comparing raw log data if needed. + + return discrepancies +} + +// ValidateSwapBatch validates multiple swap events from the same transaction +func (v *ArbiscanValidator) ValidateSwapBatch(ctx context.Context, events []*types.SwapEvent) ([]*ValidationResult, error) { + if len(events) == 0 { + return nil, nil + } + + // All events should have the same transaction hash + txHash := events[0].TxHash + + // Fetch transaction logs once for all events + logs, err := v.getTransactionLogs(ctx, txHash) + if err != nil { + return nil, fmt.Errorf("failed to fetch logs from Arbiscan: %w", err) + } + + // Validate each event + results := make([]*ValidationResult, 0, len(events)) + for _, event := range events { + // Find matching log + var matchingLog *ArbiscanLog + for i := range logs { + logIndex := parseHexToUint64(logs[i].LogIndex) + if logIndex == uint64(event.LogIndex) { + matchingLog = &logs[i] + break + } + } + + if matchingLog == nil { + results = append(results, &ValidationResult{ + IsValid: false, + SwapEvent: event, + Discrepancies: []string{fmt.Sprintf("log index %d not found", event.LogIndex)}, + ValidatedAt: time.Now(), + }) + continue + } + + // Compare + discrepancies := v.compareSwapWithArbiscan(event, matchingLog) + results = append(results, &ValidationResult{ + IsValid: len(discrepancies) == 0, + SwapEvent: event, + ArbiscanLog: matchingLog, + Discrepancies: discrepancies, + ValidatedAt: time.Now(), + }) + } + + return results, nil +} + +// parseHexToUint64 parses a hex string to uint64 +func parseHexToUint64(hex string) uint64 { + if strings.HasPrefix(hex, "0x") { + hex = hex[2:] + } + n := new(big.Int) + n.SetString(hex, 16) + return n.Uint64() +} + +// GetValidationStats returns validation statistics +type ValidationStats struct { + TotalValidated int `json:"total_validated"` + Valid int `json:"valid"` + Invalid int `json:"invalid"` + ErrorRate float64 `json:"error_rate"` + LastValidated time.Time `json:"last_validated"` +} + +// Note: To use this validator in production: +// 1. Create validator: validator := NewArbiscanValidator(apiKey, logger, swapLogger) +// 2. After parsing a swap: result, err := validator.ValidateSwap(ctx, event) +// 3. Check result.IsValid and log discrepancies +// 4. Use for spot-checking in production or continuous validation in testing diff --git a/pkg/parsers/swap_logger.go b/pkg/parsers/swap_logger.go new file mode 100644 index 0000000..b480d73 --- /dev/null +++ b/pkg/parsers/swap_logger.go @@ -0,0 +1,216 @@ +package parsers + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/your-org/mev-bot/pkg/types" +) + +// SwapLogger logs detected swaps to files for testing and accuracy verification +type SwapLogger struct { + logDir string + mu sync.Mutex + logger types.Logger +} + +// SwapLogEntry represents a logged swap event with metadata +type SwapLogEntry struct { + Timestamp time.Time `json:"timestamp"` + SwapEvent *types.SwapEvent `json:"swap_event"` + RawLogData string `json:"raw_log_data"` // Hex-encoded log data + RawLogTopics []string `json:"raw_log_topics"` // Hex-encoded topics + Parser string `json:"parser"` // Which parser detected this +} + +// NewSwapLogger creates a new swap logger +func NewSwapLogger(logDir string, logger types.Logger) (*SwapLogger, error) { + // Create log directory if it doesn't exist + if err := os.MkdirAll(logDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + + return &SwapLogger{ + logDir: logDir, + logger: logger, + }, nil +} + +// LogSwap logs a detected swap event +func (s *SwapLogger) LogSwap(ctx context.Context, event *types.SwapEvent, rawLogData []byte, rawLogTopics []string, parser string) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Create log entry + entry := SwapLogEntry{ + Timestamp: time.Now(), + SwapEvent: event, + RawLogData: fmt.Sprintf("0x%x", rawLogData), + RawLogTopics: rawLogTopics, + Parser: parser, + } + + // Marshal to JSON + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal log entry: %w", err) + } + + // Create filename based on timestamp and tx hash + filename := fmt.Sprintf("%s_%s.json", + time.Now().Format("2006-01-02_15-04-05"), + event.TxHash.Hex()[2:10], // First 8 chars of tx hash + ) + + // Write to file + logPath := filepath.Join(s.logDir, filename) + if err := os.WriteFile(logPath, data, 0644); err != nil { + return fmt.Errorf("failed to write log file: %w", err) + } + + s.logger.Debug("logged swap event", + "txHash", event.TxHash.Hex(), + "protocol", event.Protocol, + "parser", parser, + "logPath", logPath, + ) + + return nil +} + +// LogSwapBatch logs multiple swap events from the same transaction +func (s *SwapLogger) LogSwapBatch(ctx context.Context, events []*types.SwapEvent, parser string) error { + if len(events) == 0 { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Create batch entry + type BatchEntry struct { + Timestamp time.Time `json:"timestamp"` + TxHash string `json:"tx_hash"` + Parser string `json:"parser"` + SwapCount int `json:"swap_count"` + Swaps []*types.SwapEvent `json:"swaps"` + } + + entry := BatchEntry{ + Timestamp: time.Now(), + TxHash: events[0].TxHash.Hex(), + Parser: parser, + SwapCount: len(events), + Swaps: events, + } + + // Marshal to JSON + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal batch entry: %w", err) + } + + // Create filename + filename := fmt.Sprintf("%s_%s_batch.json", + time.Now().Format("2006-01-02_15-04-05"), + events[0].TxHash.Hex()[2:10], + ) + + // Write to file + logPath := filepath.Join(s.logDir, filename) + if err := os.WriteFile(logPath, data, 0644); err != nil { + return fmt.Errorf("failed to write batch log file: %w", err) + } + + s.logger.Debug("logged swap batch", + "txHash", events[0].TxHash.Hex(), + "swapCount", len(events), + "parser", parser, + "logPath", logPath, + ) + + return nil +} + +// GetLogDir returns the log directory path +func (s *SwapLogger) GetLogDir() string { + return s.logDir +} + +// LoadSwapLog loads a swap log entry from a file +func LoadSwapLog(filePath string) (*SwapLogEntry, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read log file: %w", err) + } + + var entry SwapLogEntry + if err := json.Unmarshal(data, &entry); err != nil { + return nil, fmt.Errorf("failed to unmarshal log entry: %w", err) + } + + return &entry, nil +} + +// ListSwapLogs returns all swap log files in the log directory +func (s *SwapLogger) ListSwapLogs() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := os.ReadDir(s.logDir) + if err != nil { + return nil, fmt.Errorf("failed to read log directory: %w", err) + } + + var logFiles []string + for _, entry := range entries { + if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" { + logFiles = append(logFiles, filepath.Join(s.logDir, entry.Name())) + } + } + + return logFiles, nil +} + +// CleanOldLogs removes log files older than the specified duration +func (s *SwapLogger) CleanOldLogs(maxAge time.Duration) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := os.ReadDir(s.logDir) + if err != nil { + return 0, fmt.Errorf("failed to read log directory: %w", err) + } + + cutoff := time.Now().Add(-maxAge) + removed := 0 + + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + + info, err := entry.Info() + if err != nil { + s.logger.Warn("failed to get file info", "file", entry.Name(), "error", err) + continue + } + + if info.ModTime().Before(cutoff) { + filePath := filepath.Join(s.logDir, entry.Name()) + if err := os.Remove(filePath); err != nil { + s.logger.Warn("failed to remove old log file", "file", filePath, "error", err) + continue + } + removed++ + } + } + + s.logger.Info("cleaned old swap logs", "removed", removed, "maxAge", maxAge) + return removed, nil +} diff --git a/pkg/parsers/uniswap_v2.go b/pkg/parsers/uniswap_v2.go new file mode 100644 index 0000000..83f4a41 --- /dev/null +++ b/pkg/parsers/uniswap_v2.go @@ -0,0 +1,167 @@ +package parsers + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/your-org/mev-bot/pkg/cache" + mevtypes "github.com/your-org/mev-bot/pkg/types" +) + +// UniswapV2 Swap event signature: +// event Swap(address indexed sender, uint amount0In, uint amount1In, uint amount0Out, uint amount1Out, address indexed to) +var ( + // SwapEventSignature is the event signature for UniswapV2 Swap events + SwapEventSignature = crypto.Keccak256Hash([]byte("Swap(address,uint256,uint256,uint256,uint256,address)")) +) + +// UniswapV2Parser implements the Parser interface for UniswapV2 pools +type UniswapV2Parser struct { + cache cache.PoolCache + logger mevtypes.Logger +} + +// NewUniswapV2Parser creates a new UniswapV2 parser +func NewUniswapV2Parser(cache cache.PoolCache, logger mevtypes.Logger) *UniswapV2Parser { + return &UniswapV2Parser{ + cache: cache, + logger: logger, + } +} + +// Protocol returns the protocol type this parser handles +func (p *UniswapV2Parser) Protocol() mevtypes.ProtocolType { + return mevtypes.ProtocolUniswapV2 +} + +// SupportsLog checks if this parser can handle the given log +func (p *UniswapV2Parser) SupportsLog(log types.Log) bool { + // Check if log has the Swap event signature + if len(log.Topics) == 0 { + return false + } + return log.Topics[0] == SwapEventSignature +} + +// ParseLog parses a UniswapV2 Swap event from a log +func (p *UniswapV2Parser) ParseLog(ctx context.Context, log types.Log, tx *types.Transaction) (*mevtypes.SwapEvent, error) { + // Verify this is a Swap event + if !p.SupportsLog(log) { + return nil, fmt.Errorf("unsupported log") + } + + // Get pool info from cache to extract token addresses and decimals + poolInfo, err := p.cache.GetByAddress(ctx, log.Address) + if err != nil { + return nil, fmt.Errorf("pool not found in cache: %w", err) + } + + // Parse event data + // Data contains: amount0In, amount1In, amount0Out, amount1Out (non-indexed) + // Topics contain: [signature, sender, to] (indexed) + if len(log.Topics) != 3 { + return nil, fmt.Errorf("invalid number of topics: expected 3, got %d", len(log.Topics)) + } + + // Define ABI for data decoding + uint256Type, err := abi.NewType("uint256", "", nil) + if err != nil { + return nil, fmt.Errorf("failed to create uint256 type: %w", err) + } + + arguments := abi.Arguments{ + {Type: uint256Type, Name: "amount0In"}, + {Type: uint256Type, Name: "amount1In"}, + {Type: uint256Type, Name: "amount0Out"}, + {Type: uint256Type, Name: "amount1Out"}, + } + + // Decode data + values, err := arguments.Unpack(log.Data) + if err != nil { + return nil, fmt.Errorf("failed to decode event data: %w", err) + } + + if len(values) != 4 { + return nil, fmt.Errorf("invalid number of values: expected 4, got %d", len(values)) + } + + // Extract indexed parameters from topics + sender := common.BytesToAddress(log.Topics[1].Bytes()) + recipient := common.BytesToAddress(log.Topics[2].Bytes()) + + // Extract amounts from decoded data + amount0In := values[0].(*big.Int) + amount1In := values[1].(*big.Int) + amount0Out := values[2].(*big.Int) + amount1Out := values[3].(*big.Int) + + // Scale amounts to 18 decimals for internal representation + amount0InScaled := mevtypes.ScaleToDecimals(amount0In, poolInfo.Token0Decimals, 18) + amount1InScaled := mevtypes.ScaleToDecimals(amount1In, poolInfo.Token1Decimals, 18) + amount0OutScaled := mevtypes.ScaleToDecimals(amount0Out, poolInfo.Token0Decimals, 18) + amount1OutScaled := mevtypes.ScaleToDecimals(amount1Out, poolInfo.Token1Decimals, 18) + + // Create swap event + event := &mevtypes.SwapEvent{ + TxHash: tx.Hash(), + BlockNumber: log.BlockNumber, + LogIndex: uint(log.Index), + PoolAddress: log.Address, + Protocol: mevtypes.ProtocolUniswapV2, + Token0: poolInfo.Token0, + Token1: poolInfo.Token1, + Token0Decimals: poolInfo.Token0Decimals, + Token1Decimals: poolInfo.Token1Decimals, + Amount0In: amount0InScaled, + Amount1In: amount1InScaled, + Amount0Out: amount0OutScaled, + Amount1Out: amount1OutScaled, + Sender: sender, + Recipient: recipient, + Fee: big.NewInt(int64(poolInfo.Fee)), + } + + // Validate the parsed event + if err := event.Validate(); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + p.logger.Debug("parsed UniswapV2 swap event", + "txHash", event.TxHash.Hex(), + "pool", event.PoolAddress.Hex(), + "token0", event.Token0.Hex(), + "token1", event.Token1.Hex(), + ) + + return event, nil +} + +// ParseReceipt parses all UniswapV2 Swap events from a transaction receipt +func (p *UniswapV2Parser) ParseReceipt(ctx context.Context, receipt *types.Receipt, tx *types.Transaction) ([]*mevtypes.SwapEvent, error) { + var events []*mevtypes.SwapEvent + + for _, log := range receipt.Logs { + if p.SupportsLog(*log) { + event, err := p.ParseLog(ctx, *log, tx) + if err != nil { + // Log error but continue processing other logs + p.logger.Warn("failed to parse log", + "txHash", tx.Hash().Hex(), + "logIndex", log.Index, + "error", err, + ) + continue + } + events = append(events, event) + } + } + + return events, nil +} diff --git a/pkg/parsers/uniswap_v2_test.go b/pkg/parsers/uniswap_v2_test.go new file mode 100644 index 0000000..25ee31e --- /dev/null +++ b/pkg/parsers/uniswap_v2_test.go @@ -0,0 +1,483 @@ +package parsers + +import ( + "context" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/your-org/mev-bot/pkg/cache" + mevtypes "github.com/your-org/mev-bot/pkg/types" +) + +// mockLogger implements mevtypes.Logger for testing +type mockLogger struct{} + +func (m *mockLogger) Debug(msg string, args ...any) {} +func (m *mockLogger) Info(msg string, args ...any) {} +func (m *mockLogger) Warn(msg string, args ...any) {} +func (m *mockLogger) Error(msg string, args ...any) {} +func (m *mockLogger) With(args ...any) mevtypes.Logger { + return m +} +func (m *mockLogger) WithContext(ctx context.Context) mevtypes.Logger { + return m +} + +func TestNewUniswapV2Parser(t *testing.T) { + cache := cache.NewPoolCache() + logger := &mockLogger{} + + parser := NewUniswapV2Parser(cache, logger) + + if parser == nil { + t.Fatal("NewUniswapV2Parser returned nil") + } + + if parser.cache != cache { + t.Error("NewUniswapV2Parser cache not set correctly") + } + + if parser.logger != logger { + t.Error("NewUniswapV2Parser logger not set correctly") + } +} + +func TestUniswapV2Parser_Protocol(t *testing.T) { + parser := NewUniswapV2Parser(cache.NewPoolCache(), &mockLogger{}) + + if parser.Protocol() != mevtypes.ProtocolUniswapV2 { + t.Errorf("Protocol() = %v, want %v", parser.Protocol(), mevtypes.ProtocolUniswapV2) + } +} + +func TestUniswapV2Parser_SupportsLog(t *testing.T) { + parser := NewUniswapV2Parser(cache.NewPoolCache(), &mockLogger{}) + + tests := []struct { + name string + log types.Log + want bool + }{ + { + name: "valid Swap event", + log: types.Log{ + Topics: []common.Hash{SwapEventSignature}, + }, + want: true, + }, + { + name: "empty topics", + log: types.Log{ + Topics: []common.Hash{}, + }, + want: false, + }, + { + name: "wrong event signature", + log: types.Log{ + Topics: []common.Hash{common.HexToHash("0x1234")}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parser.SupportsLog(tt.log); got != tt.want { + t.Errorf("SupportsLog() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUniswapV2Parser_ParseLog(t *testing.T) { + ctx := context.Background() + + // Create pool cache and add test pool + poolCache := cache.NewPoolCache() + poolAddress := common.HexToAddress("0x1111111111111111111111111111111111111111") + token0 := common.HexToAddress("0x2222222222222222222222222222222222222222") + token1 := common.HexToAddress("0x3333333333333333333333333333333333333333") + + testPool := &mevtypes.PoolInfo{ + Address: poolAddress, + Protocol: mevtypes.ProtocolUniswapV2, + Token0: token0, + Token1: token1, + Token0Decimals: 18, + Token1Decimals: 6, + Reserve0: big.NewInt(1000000), + Reserve1: big.NewInt(500000), + Fee: 30, // 0.3% in basis points + IsActive: true, + } + + err := poolCache.Add(ctx, testPool) + if err != nil { + t.Fatalf("Failed to add test pool: %v", err) + } + + parser := NewUniswapV2Parser(poolCache, &mockLogger{}) + + // Create test transaction + tx := types.NewTransaction( + 0, + poolAddress, + big.NewInt(0), + 0, + big.NewInt(0), + []byte{}, + ) + + // Encode event data: amount0In, amount1In, amount0Out, amount1Out + amount0In := big.NewInt(1000000000000000000) // 1 token0 (18 decimals) + amount1In := big.NewInt(0) + amount0Out := big.NewInt(0) + amount1Out := big.NewInt(500000) // 0.5 token1 (6 decimals) + + // ABI encode the amounts + data := make([]byte, 128) // 4 * 32 bytes + amount0In.FillBytes(data[0:32]) + amount1In.FillBytes(data[32:64]) + amount0Out.FillBytes(data[64:96]) + amount1Out.FillBytes(data[96:128]) + + sender := common.HexToAddress("0x4444444444444444444444444444444444444444") + recipient := common.HexToAddress("0x5555555555555555555555555555555555555555") + + tests := []struct { + name string + log types.Log + wantErr bool + }{ + { + name: "valid swap event", + log: types.Log{ + Address: poolAddress, + Topics: []common.Hash{ + SwapEventSignature, + common.BytesToHash(sender.Bytes()), + common.BytesToHash(recipient.Bytes()), + }, + Data: data, + BlockNumber: 1000, + Index: 0, + }, + wantErr: false, + }, + { + name: "unsupported log", + log: types.Log{ + Address: poolAddress, + Topics: []common.Hash{ + common.HexToHash("0x1234"), + }, + Data: data, + BlockNumber: 1000, + Index: 0, + }, + wantErr: true, + }, + { + name: "invalid number of topics", + log: types.Log{ + Address: poolAddress, + Topics: []common.Hash{ + SwapEventSignature, + common.BytesToHash(sender.Bytes()), + // Missing recipient topic + }, + Data: data, + BlockNumber: 1000, + Index: 0, + }, + wantErr: true, + }, + { + name: "pool not in cache", + log: types.Log{ + Address: common.HexToAddress("0x9999999999999999999999999999999999999999"), + Topics: []common.Hash{ + SwapEventSignature, + common.BytesToHash(sender.Bytes()), + common.BytesToHash(recipient.Bytes()), + }, + Data: data, + BlockNumber: 1000, + Index: 0, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event, err := parser.ParseLog(ctx, tt.log, tx) + + if tt.wantErr { + if err == nil { + t.Error("ParseLog() expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("ParseLog() unexpected error: %v", err) + } + + if event == nil { + t.Fatal("ParseLog() returned nil event") + } + + // Verify event fields + if event.TxHash != tx.Hash() { + t.Errorf("TxHash = %v, want %v", event.TxHash, tx.Hash()) + } + + if event.BlockNumber != tt.log.BlockNumber { + t.Errorf("BlockNumber = %v, want %v", event.BlockNumber, tt.log.BlockNumber) + } + + if event.LogIndex != uint(tt.log.Index) { + t.Errorf("LogIndex = %v, want %v", event.LogIndex, tt.log.Index) + } + + if event.PoolAddress != poolAddress { + t.Errorf("PoolAddress = %v, want %v", event.PoolAddress, poolAddress) + } + + if event.Protocol != mevtypes.ProtocolUniswapV2 { + t.Errorf("Protocol = %v, want %v", event.Protocol, mevtypes.ProtocolUniswapV2) + } + + if event.Token0 != token0 { + t.Errorf("Token0 = %v, want %v", event.Token0, token0) + } + + if event.Token1 != token1 { + t.Errorf("Token1 = %v, want %v", event.Token1, token1) + } + + if event.Token0Decimals != 18 { + t.Errorf("Token0Decimals = %v, want 18", event.Token0Decimals) + } + + if event.Token1Decimals != 6 { + t.Errorf("Token1Decimals = %v, want 6", event.Token1Decimals) + } + + if event.Sender != sender { + t.Errorf("Sender = %v, want %v", event.Sender, sender) + } + + if event.Recipient != recipient { + t.Errorf("Recipient = %v, want %v", event.Recipient, recipient) + } + + // Verify amounts are scaled to 18 decimals + expectedAmount0In := amount0In // Already 18 decimals + expectedAmount1Out := mevtypes.ScaleToDecimals(amount1Out, 6, 18) + + if event.Amount0In.Cmp(expectedAmount0In) != 0 { + t.Errorf("Amount0In = %v, want %v", event.Amount0In, expectedAmount0In) + } + + if event.Amount1Out.Cmp(expectedAmount1Out) != 0 { + t.Errorf("Amount1Out = %v, want %v", event.Amount1Out, expectedAmount1Out) + } + + if event.Amount1In.Cmp(big.NewInt(0)) != 0 { + t.Errorf("Amount1In = %v, want 0", event.Amount1In) + } + + if event.Amount0Out.Cmp(big.NewInt(0)) != 0 { + t.Errorf("Amount0Out = %v, want 0", event.Amount0Out) + } + }) + } +} + +func TestUniswapV2Parser_ParseReceipt(t *testing.T) { + ctx := context.Background() + + // Create pool cache and add test pool + poolCache := cache.NewPoolCache() + poolAddress := common.HexToAddress("0x1111111111111111111111111111111111111111") + token0 := common.HexToAddress("0x2222222222222222222222222222222222222222") + token1 := common.HexToAddress("0x3333333333333333333333333333333333333333") + + testPool := &mevtypes.PoolInfo{ + Address: poolAddress, + Protocol: mevtypes.ProtocolUniswapV2, + Token0: token0, + Token1: token1, + Token0Decimals: 18, + Token1Decimals: 6, + Reserve0: big.NewInt(1000000), + Reserve1: big.NewInt(500000), + Fee: 30, // 0.3% in basis points + IsActive: true, + } + + err := poolCache.Add(ctx, testPool) + if err != nil { + t.Fatalf("Failed to add test pool: %v", err) + } + + parser := NewUniswapV2Parser(poolCache, &mockLogger{}) + + // Create test transaction + tx := types.NewTransaction( + 0, + poolAddress, + big.NewInt(0), + 0, + big.NewInt(0), + []byte{}, + ) + + // Encode event data + amount0In := big.NewInt(1000000000000000000) + amount1In := big.NewInt(0) + amount0Out := big.NewInt(0) + amount1Out := big.NewInt(500000) + + data := make([]byte, 128) + amount0In.FillBytes(data[0:32]) + amount1In.FillBytes(data[32:64]) + amount0Out.FillBytes(data[64:96]) + amount1Out.FillBytes(data[96:128]) + + sender := common.HexToAddress("0x4444444444444444444444444444444444444444") + recipient := common.HexToAddress("0x5555555555555555555555555555555555555555") + + tests := []struct { + name string + receipt *types.Receipt + wantCount int + }{ + { + name: "receipt with single swap event", + receipt: &types.Receipt{ + Logs: []*types.Log{ + { + Address: poolAddress, + Topics: []common.Hash{ + SwapEventSignature, + common.BytesToHash(sender.Bytes()), + common.BytesToHash(recipient.Bytes()), + }, + Data: data, + BlockNumber: 1000, + Index: 0, + }, + }, + }, + wantCount: 1, + }, + { + name: "receipt with multiple swap events", + receipt: &types.Receipt{ + Logs: []*types.Log{ + { + Address: poolAddress, + Topics: []common.Hash{ + SwapEventSignature, + common.BytesToHash(sender.Bytes()), + common.BytesToHash(recipient.Bytes()), + }, + Data: data, + BlockNumber: 1000, + Index: 0, + }, + { + Address: poolAddress, + Topics: []common.Hash{ + SwapEventSignature, + common.BytesToHash(sender.Bytes()), + common.BytesToHash(recipient.Bytes()), + }, + Data: data, + BlockNumber: 1000, + Index: 1, + }, + }, + }, + wantCount: 2, + }, + { + name: "receipt with mixed events", + receipt: &types.Receipt{ + Logs: []*types.Log{ + { + Address: poolAddress, + Topics: []common.Hash{ + SwapEventSignature, + common.BytesToHash(sender.Bytes()), + common.BytesToHash(recipient.Bytes()), + }, + Data: data, + BlockNumber: 1000, + Index: 0, + }, + { + Address: poolAddress, + Topics: []common.Hash{ + common.HexToHash("0x1234"), // Different event + }, + Data: []byte{}, + BlockNumber: 1000, + Index: 1, + }, + }, + }, + wantCount: 1, // Only the Swap event + }, + { + name: "empty receipt", + receipt: &types.Receipt{ + Logs: []*types.Log{}, + }, + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + events, err := parser.ParseReceipt(ctx, tt.receipt, tx) + + if err != nil { + t.Fatalf("ParseReceipt() unexpected error: %v", err) + } + + if len(events) != tt.wantCount { + t.Errorf("ParseReceipt() returned %d events, want %d", len(events), tt.wantCount) + } + + // Verify all returned events are valid + for i, event := range events { + if event == nil { + t.Errorf("Event %d is nil", i) + continue + } + + if event.Protocol != mevtypes.ProtocolUniswapV2 { + t.Errorf("Event %d Protocol = %v, want %v", i, event.Protocol, mevtypes.ProtocolUniswapV2) + } + } + }) + } +} + +func TestSwapEventSignature(t *testing.T) { + // Verify the event signature is correct + expected := crypto.Keccak256Hash([]byte("Swap(address,uint256,uint256,uint256,uint256,address)")) + + if SwapEventSignature != expected { + t.Errorf("SwapEventSignature = %v, want %v", SwapEventSignature, expected) + } +}