diff --git a/tools/preconf-rpc/main.go b/tools/preconf-rpc/main.go index f1fe4add8..5e2fdb7d6 100644 --- a/tools/preconf-rpc/main.go +++ b/tools/preconf-rpc/main.go @@ -223,13 +223,20 @@ var ( Value: "", } - optionSimulationURL = &cli.StringFlag{ + optionSimulationURLs = &cli.StringSliceFlag{ Name: "simulation-url", - Usage: "URL for the transaction simulation service", + Usage: "URL(s) for the transaction simulation service. Multiple URLs can be specified for fallback support (first URL is primary, others are fallbacks)", EnvVars: []string{"PRECONF_RPC_SIMULATION_URL"}, Required: true, } + optionUseInlineSimulation = &cli.BoolFlag{ + Name: "use-inline-simulation", + Usage: "Use inline simulation via debug_traceCall instead of external rethsim service. When false (default), uses external rethsim. When true, uses debug_traceCall (requires RPC with debug API support like Alchemy, Infura, or Erigon)", + EnvVars: []string{"PRECONF_RPC_USE_INLINE_SIMULATION"}, + Value: false, + } + optionBackrunnerAPIURL = &cli.StringFlag{ Name: "backrunner-api-url", Usage: "URL for the transaction backrun service", @@ -357,7 +364,8 @@ func main() { optionBidderThreshold, optionBidderTopup, optionAuthToken, - optionSimulationURL, + optionSimulationURLs, + optionUseInlineSimulation, optionBackrunnerAPIURL, optionBackrunnerRPCURL, optionBackrunnerAPIKey, @@ -458,7 +466,8 @@ func main() { PricerAPIKey: c.String(optionBlocknativeAPIKey.Name), Webhooks: c.StringSlice(optionWebhookURLs.Name), Token: c.String(optionAuthToken.Name), - SimulatorURL: c.String(optionSimulationURL.Name), + SimulatorURLs: c.StringSlice(optionSimulationURLs.Name), + UseInlineSimulation: c.Bool(optionUseInlineSimulation.Name), BackrunnerAPIURL: c.String(optionBackrunnerAPIURL.Name), BackrunnerRPC: c.String(optionBackrunnerRPCURL.Name), BackrunnerAPIKey: c.String(optionBackrunnerAPIKey.Name), diff --git a/tools/preconf-rpc/service/service.go b/tools/preconf-rpc/service/service.go index f37515c2b..55dfa6be3 100644 --- a/tools/preconf-rpc/service/service.go +++ b/tools/preconf-rpc/service/service.go @@ -78,7 +78,8 @@ type Config struct { PricerAPIKey string Webhooks []string Token string - SimulatorURL string + SimulatorURLs []string + UseInlineSimulation bool BackrunnerRPC string BackrunnerAPIURL string BackrunnerAPIKey string @@ -270,8 +271,31 @@ func New(config *Config) (*Service, error) { healthChecker.Register(health.CloseChannelHealthCheck("BlockTracker", blockTrackerDone)) s.closers = append(s.closers, channelCloser(blockTrackerDone)) - simulator := sim.NewSimulator(config.SimulatorURL) - metricsRegistry.MustRegister(simulator.Metrics()...) + // Create simulator based on feature flag + // When UseInlineSimulation is true, uses debug_traceCall via standard RPC (Alchemy, Infura, Erigon) + // When false (default), uses external rethsim API for backward compatibility + // Multiple URLs can be provided for fallback support + if len(config.SimulatorURLs) == 0 { + return nil, fmt.Errorf("at least one simulation URL is required") + } + var simulator sender.Simulator + var simulatorMetrics []prometheus.Collector + if config.UseInlineSimulation { + inlineSim, err := sim.NewInlineSimulator(config.SimulatorURLs, config.Logger) + if err != nil { + return nil, fmt.Errorf("failed to create inline simulator: %w", err) + } + simulator = inlineSim + simulatorMetrics = inlineSim.Metrics() + s.closers = append(s.closers, inlineSim) // close RPC clients on shutdown + config.Logger.Info("using inline simulator (debug_traceCall)", "endpointCount", len(config.SimulatorURLs)) + } else { + externalSim := sim.NewSimulator(config.SimulatorURLs, config.Logger) + simulator = externalSim + simulatorMetrics = externalSim.Metrics() + config.Logger.Info("using external simulator (rethsim)", "endpointCount", len(config.SimulatorURLs)) + } + metricsRegistry.MustRegister(simulatorMetrics...) var pointsTracker PointsTracker if config.PointsAPIURL == "" { diff --git a/tools/preconf-rpc/sim/inline_simulator.go b/tools/preconf-rpc/sim/inline_simulator.go new file mode 100644 index 000000000..7a8ba9c16 --- /dev/null +++ b/tools/preconf-rpc/sim/inline_simulator.go @@ -0,0 +1,424 @@ +package sim + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/prometheus/client_golang/prometheus" +) + +// TraceLog represents a log entry from simulation. +type TraceLog struct { + Address common.Address `json:"address"` + Topics []common.Hash `json:"topics"` + Data hexutil.Bytes `json:"data"` +} + +// SimulateV1CallResult represents a call result from eth_simulateV1. +type SimulateV1CallResult struct { + Status hexutil.Uint64 `json:"status"` + GasUsed hexutil.Uint64 `json:"gasUsed"` + ReturnData hexutil.Bytes `json:"returnData"` + Logs []TraceLog `json:"logs"` + Error *SimulateError `json:"error,omitempty"` +} + +// SimulateError represents an error returned by eth_simulateV1. +type SimulateError struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data,omitempty"` +} + +// SimulateV1Block represents a block result from eth_simulateV1. +type SimulateV1Block struct { + Number hexutil.Uint64 `json:"number"` + GasUsed hexutil.Uint64 `json:"gasUsed"` + Calls []SimulateV1CallResult `json:"calls"` +} + +// TraceCallResult represents the result of debug_traceCall with callTracer. +type TraceCallResult struct { + Type string `json:"type"` + From string `json:"from"` + To string `json:"to"` + Value string `json:"value,omitempty"` + Gas string `json:"gas"` + GasUsed string `json:"gasUsed"` + Input string `json:"input"` + Output string `json:"output"` + Error string `json:"error,omitempty"` + Calls []TraceCallResult `json:"calls,omitempty"` + Logs []TraceLog `json:"logs,omitempty"` +} + +type rpcEndpoint struct { + client *rpc.Client +} + +// InlineSimulator simulates transactions using eth_simulateV1 with debug_traceCall as fallback. +// It prefers eth_simulateV1 for better performance, falling back to debug_traceCall when +// the RPC doesn't support eth_simulateV1. Multiple endpoints can be configured for redundancy. +type InlineSimulator struct { + endpoints []rpcEndpoint + metrics *metrics + logger *slog.Logger +} + +// NewInlineSimulator creates a simulator with the given RPC endpoints. +// The first URL is primary; others are used as fallbacks on network errors. +func NewInlineSimulator(rpcURLs []string, logger *slog.Logger) (*InlineSimulator, error) { + if len(rpcURLs) == 0 { + return nil, errors.New("at least one RPC URL is required") + } + + endpoints := make([]rpcEndpoint, 0, len(rpcURLs)) + for i, url := range rpcURLs { + client, err := rpc.Dial(url) + if err != nil { + if logger != nil { + logger.Warn("failed to connect to RPC endpoint", "endpointIndex", i, "error", err) + } + continue + } + endpoints = append(endpoints, rpcEndpoint{client: client}) + } + + if len(endpoints) == 0 { + return nil, fmt.Errorf("failed to connect to any RPC endpoint") + } + + if logger == nil { + logger = slog.Default() + } + + return &InlineSimulator{ + endpoints: endpoints, + metrics: newMetrics(), + logger: logger, + }, nil +} + +// Metrics returns prometheus collectors for monitoring. +func (s *InlineSimulator) Metrics() []prometheus.Collector { + return []prometheus.Collector{ + s.metrics.attempts, + s.metrics.success, + s.metrics.fail, + s.metrics.latency, + } +} + +// Simulate runs a transaction simulation and returns logs, swap detection result, and any error. +// State can be "latest" or "pending". +func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimState) ([]*types.Log, bool, error) { + start := time.Now() + defer func() { + s.metrics.latency.Observe(float64(time.Since(start).Milliseconds())) + }() + + s.metrics.attempts.Inc() + + rawBytes, err := hex.DecodeString(strings.TrimPrefix(txRaw, "0x")) + if err != nil { + s.metrics.fail.Inc() + return nil, false, fmt.Errorf("invalid hex: %w", err) + } + + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(rawBytes); err != nil { + s.metrics.fail.Inc() + return nil, false, fmt.Errorf("invalid transaction: %w", err) + } + + signer := types.LatestSignerForChainID(tx.ChainId()) + sender, err := types.Sender(signer, tx) + if err != nil { + s.metrics.fail.Inc() + return nil, false, fmt.Errorf("failed to get sender: %w", err) + } + + // Build call object. We use "input" here; debug_traceCall expects "data" so we convert later. + callObj := map[string]interface{}{ + "from": sender.Hex(), + "gas": hexutil.Uint64(tx.Gas()), + "value": hexutil.EncodeBig(tx.Value()), + "input": hexutil.Encode(tx.Data()), + } + if tx.To() != nil { + callObj["to"] = tx.To().Hex() + } + + // Set gas price fields based on tx type (EIP-1559 vs legacy) + switch tx.Type() { + case types.DynamicFeeTxType, types.BlobTxType: + callObj["maxFeePerGas"] = hexutil.EncodeBig(tx.GasFeeCap()) + callObj["maxPriorityFeePerGas"] = hexutil.EncodeBig(tx.GasTipCap()) + default: + callObj["gasPrice"] = hexutil.EncodeBig(tx.GasPrice()) + } + + logs, isSwap, err := s.simulateWithFallback(ctx, callObj, state) + if err != nil { + s.metrics.fail.Inc() + return nil, false, err + } + + s.metrics.success.Inc() + return logs, isSwap, nil +} + +// simulateWithFallback tries endpoints in order, using eth_simulateV1 first then debug_traceCall. +func (s *InlineSimulator) simulateWithFallback(ctx context.Context, callObj map[string]interface{}, state SimState) ([]*types.Log, bool, error) { + var lastErr error + + for i, endpoint := range s.endpoints { + logs, isSwap, err := s.executeSimulateV1(ctx, endpoint.client, callObj, state) + if err == nil { + if i > 0 { + s.logger.Info("simulation succeeded on fallback endpoint", "endpointIndex", i, "method", "eth_simulateV1") + } + return logs, isSwap, nil + } + + // If eth_simulateV1 isn't supported, try debug_traceCall on the same endpoint + if isMethodNotSupported(err) { + s.logger.Debug("eth_simulateV1 not supported, trying debug_traceCall", "endpointIndex", i) + logs, isSwap, err = s.executeDebugTraceCall(ctx, endpoint.client, callObj, state) + if err == nil { + if i > 0 { + s.logger.Info("simulation succeeded on fallback endpoint", "endpointIndex", i, "method", "debug_traceCall") + } + return logs, isSwap, nil + } + } + + lastErr = err + + // Don't retry on application errors (reverts, bad requests). + // Only retry on transient errors (network issues, 5xx, rate limits). + if !shouldFallback(err) { + return nil, false, err + } + + s.logger.Warn("endpoint failed, trying next", + "endpointIndex", i, + "error", err, + "remainingEndpoints", len(s.endpoints)-i-1, + ) + } + + return nil, false, fmt.Errorf("all endpoints failed: %w", lastErr) +} + +// executeSimulateV1 runs simulation using eth_simulateV1. +// See: https://ethereum.github.io/execution-apis/ethsimulatev1-notes/ +func (s *InlineSimulator) executeSimulateV1(ctx context.Context, client *rpc.Client, callObj map[string]interface{}, state SimState) ([]*types.Log, bool, error) { + simRequest := map[string]interface{}{ + "blockStateCalls": []map[string]interface{}{ + {"calls": []map[string]interface{}{callObj}}, + }, + "validation": true, + } + + var result []SimulateV1Block + if err := client.CallContext(ctx, &result, "eth_simulateV1", simRequest, string(state)); err != nil { + return nil, false, err + } + + if len(result) == 0 { + return nil, false, &NonRetryableError{Err: errors.New("empty response from eth_simulateV1")} + } + block := result[0] + if len(block.Calls) == 0 { + return nil, false, &NonRetryableError{Err: errors.New("no calls in eth_simulateV1 response")} + } + + call := block.Calls[0] + + // status 0 means reverted + if call.Status == 0 { + reason := "execution reverted" + if call.Error != nil && call.Error.Message != "" { + reason = call.Error.Message + } else if len(call.ReturnData) > 0 { + reason = decodeRevert(hexutil.Encode(call.ReturnData), reason) + } + return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s", reason)} + } + + if call.GasUsed == 0 { + return nil, false, &NonRetryableError{Err: errors.New("invalid response: zero gas used")} + } + + isSwap, _ := DetectSwapsFromLogs(call.Logs) + logs := convertTraceLogs(call.Logs) + + return logs, isSwap, nil +} + +// executeDebugTraceCall runs simulation using debug_traceCall with callTracer. +func (s *InlineSimulator) executeDebugTraceCall(ctx context.Context, client *rpc.Client, callObj map[string]interface{}, state SimState) ([]*types.Log, bool, error) { + // debug_traceCall expects "data" instead of "input" + traceCallObj := make(map[string]interface{}) + for k, v := range callObj { + if k == "input" { + traceCallObj["data"] = v + } else { + traceCallObj[k] = v + } + } + + var result TraceCallResult + err := client.CallContext(ctx, &result, "debug_traceCall", + traceCallObj, + string(state), + map[string]interface{}{ + "tracer": "callTracer", + "tracerConfig": map[string]interface{}{ + "withLog": true, + "enableReturnData": true, + }, + }, + ) + if err != nil { + return nil, false, fmt.Errorf("debug_traceCall failed (state=%s): %w", state, err) + } + + if result.Error != "" { + reason := decodeRevertFromTrace(result.Output, result.Error) + return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s", reason)} + } + + // Check nested calls for reverts (e.g., inner contract call failed) + if innerErr := findInnerCallError(&result); innerErr != "" { + return nil, false, &NonRetryableError{Err: fmt.Errorf("inner call reverted: %s", innerErr)} + } + + gasUsed, err := hexutil.DecodeUint64(result.GasUsed) + if err != nil || gasUsed == 0 { + return nil, false, &NonRetryableError{Err: errors.New("invalid trace: zero gas used")} + } + + var traceLogs []TraceLog + collectTraceLogs(&result, &traceLogs) + + isSwap, _ := DetectSwapsFromLogs(traceLogs) + logs := convertTraceLogs(traceLogs) + + return logs, isSwap, nil +} + +// isMethodNotSupported checks if the error indicates the RPC method doesn't exist. +func isMethodNotSupported(err error) bool { + if err == nil { + return false + } + var rpcErr rpc.Error + if errors.As(err, &rpcErr) { + code := rpcErr.ErrorCode() + // -32601: Method not found, -32600: Invalid Request + if code == -32601 || code == -32600 { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "method not found") || + strings.Contains(msg, "not supported") || + strings.Contains(msg, "unknown method") + } + return false +} + +// shouldFallback determines if we should try the next endpoint. +// Returns false for application errors (reverts, bad requests) since retrying won't help. +// Returns true for transient errors (network issues, 5xx, rate limits). +func shouldFallback(err error) bool { + if err == nil { + return false + } + + var nonRetryable *NonRetryableError + if errors.As(err, &nonRetryable) { + return false + } + + var rpcErr rpc.Error + if errors.As(err, &rpcErr) { + return false + } + + var httpErr rpc.HTTPError + if errors.As(err, &httpErr) { + // 4xx (except 429) are client errors, don't retry + if httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 && httpErr.StatusCode != 429 { + return false + } + return true + } + + return true +} + +// findInnerCallError recursively searches for errors in nested calls. +func findInnerCallError(call *TraceCallResult) string { + for i := range call.Calls { + if call.Calls[i].Error != "" { + reason := decodeRevertFromTrace(call.Calls[i].Output, call.Calls[i].Error) + return fmt.Sprintf("%s (to=%s, type=%s)", reason, call.Calls[i].To, call.Calls[i].Type) + } + if innerErr := findInnerCallError(&call.Calls[i]); innerErr != "" { + return innerErr + } + } + return "" +} + +// collectTraceLogs gathers logs from the trace in execution order (depth-first). +func collectTraceLogs(call *TraceCallResult, logs *[]TraceLog) { + *logs = append(*logs, call.Logs...) + for i := range call.Calls { + collectTraceLogs(&call.Calls[i], logs) + } +} + +func convertTraceLogs(traceLogs []TraceLog) []*types.Log { + logs := make([]*types.Log, 0, len(traceLogs)) + for i, tl := range traceLogs { + logs = append(logs, &types.Log{ + Address: tl.Address, + Topics: tl.Topics, + Data: tl.Data, + Index: uint(i), + }) + } + return logs +} + +func decodeRevertFromTrace(output string, fallback string) string { + if output == "" || output == "0x" { + return fallback + } + if reason := decodeRevert(output, ""); reason != "" { + return reason + } + return fallback +} + +// Close releases all RPC connections. +func (s *InlineSimulator) Close() error { + for _, endpoint := range s.endpoints { + if endpoint.client != nil { + endpoint.client.Close() + } + } + return nil +} diff --git a/tools/preconf-rpc/sim/inline_simulator_test.go b/tools/preconf-rpc/sim/inline_simulator_test.go new file mode 100644 index 000000000..a8f89ef0f --- /dev/null +++ b/tools/preconf-rpc/sim/inline_simulator_test.go @@ -0,0 +1,675 @@ +package sim_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/primev/mev-commit/tools/preconf-rpc/sim" +) + +// Mock eth_simulateV1 response for a successful simple transfer +var simulateV1ResponseSimple = `[{ + "number": "0x1", + "gasUsed": "0x5208", + "calls": [{ + "status": "0x1", + "gasUsed": "0x5208", + "returnData": "0x", + "logs": [] + }] +}]` + +// Mock eth_simulateV1 response for a swap transaction with SushiSwap/Uniswap V2 Swap event +var simulateV1ResponseSwap = `[{ + "number": "0x1", + "gasUsed": "0x20000", + "calls": [{ + "status": "0x1", + "gasUsed": "0x20000", + "returnData": "0x", + "logs": [{ + "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "topics": [ + "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822", + "0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d", + "0x000000000000000000000000ae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e" + ], + "data": "0x0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000" + }] + }] +}]` + +// Mock eth_simulateV1 response for a reverted transaction +var simulateV1ResponseRevert = `[{ + "number": "0x1", + "gasUsed": "0x10000", + "calls": [{ + "status": "0x0", + "gasUsed": "0x10000", + "returnData": "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001a496e73756666696369656e742062616c616e636500000000000000000000000000", + "logs": [], + "error": { + "code": 3, + "message": "execution reverted" + } + }] +}]` + +// Mock debug_traceCall response for a successful simple transfer +var traceCallResponseSimple = `{ + "type": "CALL", + "from": "0xae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e", + "to": "0x1234567890123456789012345678901234567890", + "value": "0xde0b6b3a7640000", + "gas": "0x5208", + "gasUsed": "0x5208", + "input": "0x", + "output": "0x", + "logs": [] +}` + +// Mock debug_traceCall response for a swap transaction with SushiSwap/Uniswap V2 Swap event +var traceCallResponseSwap = `{ + "type": "CALL", + "from": "0xae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e", + "to": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", + "value": "0x0", + "gas": "0x30000", + "gasUsed": "0x20000", + "input": "0x38ed1739", + "output": "0x", + "logs": [ + { + "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "topics": [ + "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822", + "0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d", + "0x000000000000000000000000ae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e" + ], + "data": "0x0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "calls": [] +}` + +// Mock debug_traceCall response for a reverted transaction +var traceCallResponseRevert = `{ + "type": "CALL", + "from": "0xae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e", + "to": "0x1234567890123456789012345678901234567890", + "value": "0x0", + "gas": "0x30000", + "gasUsed": "0x10000", + "input": "0x", + "output": "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001a496e73756666696369656e742062616c616e636500000000000000000000000000", + "error": "execution reverted", + "logs": [] +}` + +// Mock debug_traceCall response with nested calls containing Uniswap V3 swap +var traceCallResponseNestedSwap = `{ + "type": "CALL", + "from": "0xae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e", + "to": "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", + "value": "0x0", + "gas": "0x50000", + "gasUsed": "0x40000", + "input": "0x", + "output": "0x", + "logs": [], + "calls": [ + { + "type": "CALL", + "from": "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", + "to": "0xe592427a0aece92de3edee1f18e0157c05861564", + "value": "0x0", + "gas": "0x40000", + "gasUsed": "0x30000", + "input": "0x", + "output": "0x", + "logs": [ + { + "address": "0xe592427a0aece92de3edee1f18e0157c05861564", + "topics": [ + "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67", + "0x000000000000000000000000ae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e", + "0x000000000000000000000000ae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e" + ], + "data": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" + } + ] + } + ] +}` + +// Mock debug_traceCall response for multi-hop aggregator swap +var traceCallResponseMultiHop = `{ + "type": "CALL", + "from": "0xae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e", + "to": "0x1111111254EEB25477B68fb85Ed929f73A960582", + "value": "0x0", + "gas": "0x80000", + "gasUsed": "0x60000", + "input": "0x", + "output": "0x", + "logs": [], + "calls": [ + { + "type": "CALL", + "from": "0x1111111254EEB25477B68fb85Ed929f73A960582", + "to": "0xsomepool1", + "value": "0x0", + "gas": "0x30000", + "gasUsed": "0x20000", + "input": "0x", + "output": "0x", + "logs": [ + { + "address": "0xsomepool1", + "topics": [ + "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822" + ], + "data": "0x" + } + ] + }, + { + "type": "CALL", + "from": "0x1111111254EEB25477B68fb85Ed929f73A960582", + "to": "0xsomepool2", + "value": "0x0", + "gas": "0x30000", + "gasUsed": "0x20000", + "input": "0x", + "output": "0x", + "logs": [ + { + "address": "0xsomepool2", + "topics": [ + "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67" + ], + "data": "0x" + } + ] + } + ] +}` + +// Mock debug_traceCall response for Curve StableSwap NG swap +var traceCallResponseCurve = `{ + "type": "CALL", + "from": "0xae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e", + "to": "0x99a58482BD75cbab83b27EC03CA68fF489b5788f", + "value": "0x0", + "gas": "0x50000", + "gasUsed": "0x40000", + "input": "0x", + "output": "0x", + "logs": [ + { + "address": "0xbebc44782c7db0a1a60cb6fe97d0b483032ff1c7", + "topics": [ + "0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140" + ], + "data": "0x" + } + ] +}` + +// Mock debug_traceCall response for Balancer swap +var traceCallResponseBalancer = `{ + "type": "CALL", + "from": "0xae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e", + "to": "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + "value": "0x0", + "gas": "0x100000", + "gasUsed": "0x80000", + "input": "0x", + "output": "0x", + "logs": [], + "calls": [ + { + "type": "CALL", + "from": "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + "to": "0xsomepool", + "value": "0x0", + "gas": "0x50000", + "gasUsed": "0x30000", + "input": "0x", + "output": "0x", + "logs": [ + { + "address": "0xsomepool", + "topics": [ + "0x2170c741c41531aec20e7c107c24eecfdd15e69c9bb0a8dd37b1840b9e0b207b" + ], + "data": "0x" + } + ] + } + ] +}` + +func TestInlineSimulator(t *testing.T) { + // eth_simulateV1 responses + simV1Responses := map[string]string{ + "simple": simulateV1ResponseSimple, + "swap": simulateV1ResponseSwap, + "revert": simulateV1ResponseRevert, + } + + // debug_traceCall responses (used as fallback) + traceResponses := map[string]string{ + "simple": traceCallResponseSimple, + "swap": traceCallResponseSwap, + "revert": traceCallResponseRevert, + "nestedSwap": traceCallResponseNestedSwap, + "multiHop": traceCallResponseMultiHop, + "curve": traceCallResponseCurve, + "balancer": traceCallResponseBalancer, + } + + // Helper to create test server with configurable eth_simulateV1 support + createTestServer := func(supportSimulateV1 bool) *httptest.Server { + return httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + ID int `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + defer func() { _ = r.Body.Close() }() + + w.Header().Set("Content-Type", "application/json") + + if req.Method == "eth_simulateV1" { + if !supportSimulateV1 { + // Return JSON-RPC error for method not found + response := map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "error": map[string]interface{}{ + "code": -32601, + "message": "Method not found", + }, + } + _ = json.NewEncoder(w).Encode(response) + return + } + + // Parse the simulateV1 request to get the call + var simReq map[string]interface{} + if err := json.Unmarshal(req.Params[0], &simReq); err != nil { + http.Error(w, "bad params", http.StatusBadRequest) + return + } + + // Get the call object + blockStateCalls, _ := simReq["blockStateCalls"].([]interface{}) + if len(blockStateCalls) == 0 { + http.Error(w, "no block state calls", http.StatusBadRequest) + return + } + blockState, _ := blockStateCalls[0].(map[string]interface{}) + calls, _ := blockState["calls"].([]interface{}) + if len(calls) == 0 { + http.Error(w, "no calls", http.StatusBadRequest) + return + } + callObj, _ := calls[0].(map[string]interface{}) + to, _ := callObj["to"].(string) + + var responseKey string + switch strings.ToLower(to) { + case "0x1234567890123456789012345678901234567890": + if input, ok := callObj["input"].(string); ok && input == "0xrevert" { + responseKey = "revert" + } else { + responseKey = "simple" + } + case "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": + responseKey = "swap" + default: + responseKey = "simple" + } + + response := map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "result": json.RawMessage(simV1Responses[responseKey]), + } + _ = json.NewEncoder(w).Encode(response) + return + } + + if req.Method == "debug_traceCall" { + // Parse the call object to get the "to" address for routing + var callObj map[string]interface{} + if err := json.Unmarshal(req.Params[0], &callObj); err != nil { + http.Error(w, "bad params", http.StatusBadRequest) + return + } + + // Route based on the "to" address + to, _ := callObj["to"].(string) + var responseKey string + switch strings.ToLower(to) { + case "0x1234567890123456789012345678901234567890": + if data, ok := callObj["data"].(string); ok && data == "0xrevert" { + responseKey = "revert" + } else { + responseKey = "simple" + } + case "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": + responseKey = "swap" + case "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45": + responseKey = "nestedSwap" + case "0x1111111254eeb25477b68fb85ed929f73a960582": + responseKey = "multiHop" + case "0x99a58482bd75cbab83b27ec03ca68ff489b5788f": + responseKey = "curve" + case "0x9008d19f58aabd9ed0d60971565aa8510560ab41": + responseKey = "balancer" + default: + responseKey = "simple" + } + + response := map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "result": json.RawMessage(traceResponses[responseKey]), + } + _ = json.NewEncoder(w).Encode(response) + return + } + + // Unknown method + response := map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "error": map[string]interface{}{ + "code": -32601, + "message": "Method not found", + }, + } + _ = json.NewEncoder(w).Encode(response) + }), + ) + } + + // Test with eth_simulateV1 support + t.Run("WithSimulateV1Support", func(t *testing.T) { + srv := createTestServer(true) + defer srv.Close() + + simulator, err := sim.NewInlineSimulator([]string{srv.URL}, nil) + if err != nil { + t.Fatalf("failed to create inline simulator: %v", err) + } + defer func() { _ = simulator.Close() }() + + t.Run("InvalidTransaction", func(t *testing.T) { + _, _, err := simulator.Simulate(context.Background(), "invalid", sim.Latest) + if err == nil { + t.Error("expected error for invalid transaction") + } + }) + + t.Run("InvalidHex", func(t *testing.T) { + _, _, err := simulator.Simulate(context.Background(), "0xZZZZ", sim.Latest) + if err == nil { + t.Error("expected error for invalid hex") + } + }) + }) + + // Test fallback to debug_traceCall when eth_simulateV1 is not supported + t.Run("FallbackToDebugTraceCall", func(t *testing.T) { + srv := createTestServer(false) + defer srv.Close() + + simulator, err := sim.NewInlineSimulator([]string{srv.URL}, nil) + if err != nil { + t.Fatalf("failed to create inline simulator: %v", err) + } + defer func() { _ = simulator.Close() }() + + t.Run("InvalidTransaction", func(t *testing.T) { + _, _, err := simulator.Simulate(context.Background(), "invalid", sim.Latest) + if err == nil { + t.Error("expected error for invalid transaction") + } + }) + + t.Run("InvalidHex", func(t *testing.T) { + _, _, err := simulator.Simulate(context.Background(), "0xZZZZ", sim.Latest) + if err == nil { + t.Error("expected error for invalid hex") + } + }) + }) +} + +// TestSwapDetection tests the swap detector with realistic trace responses +func TestSwapDetection(t *testing.T) { + // Test nested trace logs collection from aggregator multi-hop + t.Run("NestedTraceLogCollection", func(t *testing.T) { + // Simulate what happens in a multi-hop swap + // The logs are nested inside calls + logs := []sim.TraceLog{ + // First hop - SushiSwap (uses same signature as Uniswap V2 Swap) + { + Topics: []common.Hash{ + common.HexToHash("0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822"), + }, + }, + // Second hop - Uniswap V3 + { + Topics: []common.Hash{ + common.HexToHash("0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"), + }, + }, + } + + isSwap, kinds := sim.DetectSwapsFromLogs(logs) + if !isSwap { + t.Error("expected swap detection for multi-hop aggregator trade") + } + if len(kinds) != 2 { + t.Errorf("expected 2 swap kinds for multi-hop, got %v", kinds) + } + }) + + // Test that we can detect swaps even with Transfer events mixed in + t.Run("SwapWithTransferEvents", func(t *testing.T) { + logs := []sim.TraceLog{ + // Transfer event (should be ignored) + { + Topics: []common.Hash{ + common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"), + }, + }, + // Approval event (should be ignored) + { + Topics: []common.Hash{ + common.HexToHash("0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"), + }, + }, + // Actual swap event (SushiSwap/Uniswap V2 Swap) + { + Topics: []common.Hash{ + common.HexToHash("0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822"), + }, + }, + } + + isSwap, kinds := sim.DetectSwapsFromLogs(logs) + if !isSwap { + t.Error("expected swap detection even with Transfer/Approval events") + } + if len(kinds) != 1 || kinds[0] != "sushiswap_swap" { + t.Errorf("expected sushiswap_swap, got %v", kinds) + } + }) +} + +func TestSwapSignatures(t *testing.T) { + // Test all swap event signatures from rethsim + swapTests := []struct { + name string + topicHash string + expectedKind string + }{ + // Uniswap V2 Sync event (emitted on every swap) + {"UniswapV2Sync", "0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1", "uniswap_v2_swap"}, + // Uniswap V3 Swap + {"UniswapV3Swap", "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67", "uniswap_v3_swap"}, + // Uniswap V4 Swap + {"UniswapV4Swap", "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f", "uniswap_v4_swap"}, + // MetaMask Swap Router + {"MetaMaskSwapRouter", "0xbeee1e6e7fe307ddcf84b0a16137a4430ad5e2480fc4f4a8e250ab56ccd7630d", "metamask_swap_router"}, + // Fluid DEX + {"FluidSwap", "0xfbce846c23a724e6e61161894819ec46c90a8d3dd96e90e7342c6ef49ffb539c", "fluid_swap"}, + // Curve TokenExchange + {"CurveFinanceSwap", "0x56d0661e240dfb199ef196e16e6f42473990366314f0226ac978f7be3cd9ee83", "curve_finance_swap"}, + // Curve tricrypto + {"CurveTricryptoSwap", "0x143f1f8e861fbdeddd5b46e844b7d3ac7b86a122f36e8c463859ee6811b1f29c", "curve_tricrypto_swap"}, + // Curve StableSwap NG + {"CurveStableswapNGSwap", "0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140", "curve_stableswap_ng_swap"}, + // Balancer V2 Swap + {"BalancerSwap", "0x2170c741c41531aec20e7c107c24eecfdd15e69c9bb0a8dd37b1840b9e0b207b", "balancer_swap"}, + // Balancer LOG_SWAP + {"BalancerLogSwap", "0x908fb5ee8f16c6bc9bc3690973819f32a4d4b10188134543c88706e0e1d43378", "balancer_log_swap"}, + // 1inch Aggregation Router V6 + {"OneInchAggregationRouterV6", "0xfec331350fce78ba658e082a71da20ac9f8d798a99b3c79681c8440cbfe77e07", "oneinch_aggregation_router_v6"}, + // SushiSwap Swap (same signature as Uniswap V2 Swap event) + {"SushiSwapSwap", "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822", "sushiswap_swap"}, + // KyberSwap + {"KyberSwapSwap", "0xd6d4f5681c246c9f42c203e287975af1601f8df8035a9251f79aab5c8f09e2f8", "kyberswap_swap"}, + // PancakeSwap + {"PancakeSwapSwap", "0x19b47279256b2a23a1665c810c8d55a1758940ee09377d4f8d26497a3577dc83", "pancakeswap_swap"}, + // DODO + {"DODOSwap", "0xc2c0245e056d5fb095f04cd6373bc770802ebd1e6c918eb78fdef843cdb37b0f", "dodoswap_swap"}, + } + + for _, tt := range swapTests { + t.Run("Detect_"+tt.name, func(t *testing.T) { + logs := []sim.TraceLog{ + { + Topics: []common.Hash{ + common.HexToHash(tt.topicHash), + }, + }, + } + isSwap, kinds := sim.DetectSwapsFromLogs(logs) + if !isSwap { + t.Errorf("expected swap detection for %s event", tt.name) + } + if len(kinds) != 1 || kinds[0] != tt.expectedKind { + t.Errorf("expected %s swap kind, got %v", tt.expectedKind, kinds) + } + }) + } + + // Test multiple swap events in one transaction (aggregator scenario) + t.Run("DetectMultipleSwaps", func(t *testing.T) { + logs := []sim.TraceLog{ + { + Topics: []common.Hash{ + common.HexToHash("0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1"), // Uniswap V2 Sync + }, + }, + { + Topics: []common.Hash{ + common.HexToHash("0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"), // Uniswap V3 + }, + }, + { + Topics: []common.Hash{ + common.HexToHash("0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140"), // Curve StableSwap NG + }, + }, + } + isSwap, kinds := sim.DetectSwapsFromLogs(logs) + if !isSwap { + t.Error("expected swap detection for multiple swap events") + } + if len(kinds) != 3 { + t.Errorf("expected 3 swap kinds, got %v", kinds) + } + }) + + // Test deduplication of same swap type + t.Run("DeduplicateSameSwapType", func(t *testing.T) { + logs := []sim.TraceLog{ + { + Topics: []common.Hash{ + common.HexToHash("0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822"), // SushiSwap + }, + }, + { + Topics: []common.Hash{ + common.HexToHash("0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822"), // SushiSwap again + }, + }, + } + isSwap, kinds := sim.DetectSwapsFromLogs(logs) + if !isSwap { + t.Error("expected swap detection") + } + if len(kinds) != 1 || kinds[0] != "sushiswap_swap" { + t.Errorf("expected single sushiswap_swap, got %v", kinds) + } + }) + + t.Run("NoSwapDetected", func(t *testing.T) { + logs := []sim.TraceLog{ + { + Topics: []common.Hash{ + common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"), // Transfer event + }, + }, + } + isSwap, kinds := sim.DetectSwapsFromLogs(logs) + if isSwap { + t.Error("expected no swap detection for Transfer event") + } + if len(kinds) != 0 { + t.Errorf("expected no swap kinds, got %v", kinds) + } + }) + + t.Run("EmptyLogs", func(t *testing.T) { + isSwap, kinds := sim.DetectSwapsFromLogs([]sim.TraceLog{}) + if isSwap { + t.Error("expected no swap detection for empty logs") + } + if len(kinds) != 0 { + t.Errorf("expected no swap kinds, got %v", kinds) + } + }) + + t.Run("LogWithNoTopics", func(t *testing.T) { + logs := []sim.TraceLog{ + { + Topics: []common.Hash{}, + }, + } + isSwap, kinds := sim.DetectSwapsFromLogs(logs) + if isSwap { + t.Error("expected no swap detection for log with no topics") + } + if len(kinds) != 0 { + t.Errorf("expected no swap kinds, got %v", kinds) + } + }) +} diff --git a/tools/preconf-rpc/sim/simulator.go b/tools/preconf-rpc/sim/simulator.go index 8bdf59cf7..f9b4d9284 100644 --- a/tools/preconf-rpc/sim/simulator.go +++ b/tools/preconf-rpc/sim/simulator.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "log/slog" "math/big" "net" "net/http" @@ -17,6 +18,20 @@ import ( "github.com/prometheus/client_golang/prometheus" ) +// NonRetryableError wraps errors that should NOT trigger fallback to another endpoint. +// Examples: transaction reverts, invalid requests (4xx except rate limiting). +type NonRetryableError struct { + Err error +} + +func (e *NonRetryableError) Error() string { + return e.Err.Error() +} + +func (e *NonRetryableError) Unwrap() error { + return e.Err +} + type SimCall struct { Status string `json:"status"` GasUsed string `json:"gasUsed"` @@ -49,15 +64,22 @@ var ( Pending SimState = "pending" ) +// Simulator is the external rethsim simulator with fallback support type Simulator struct { - apiURL string + apiURLs []string client *http.Client metrics *metrics + logger *slog.Logger } -func NewSimulator(apiURL string) *Simulator { +// NewSimulator creates a new external simulator with fallback support +// The first URL is the primary endpoint, subsequent URLs are fallbacks +func NewSimulator(apiURLs []string, logger *slog.Logger) *Simulator { + if logger == nil { + logger = slog.Default() + } return &Simulator{ - apiURL: apiURL, + apiURLs: apiURLs, client: &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, @@ -74,6 +96,7 @@ func NewSimulator(apiURL string) *Simulator { Timeout: 15 * time.Second, }, metrics: newMetrics(), + logger: logger, } } @@ -109,23 +132,55 @@ func (s *Simulator) Simulate(ctx context.Context, txRaw string, state SimState) if err != nil { return nil, false, fmt.Errorf("marshal request: %w", err) } + + s.metrics.attempts.Inc() + + // Try each endpoint with fallback on connection errors + var lastErr error + for i, apiURL := range s.apiURLs { + logs, isSwap, err := s.doSimulate(ctx, apiURL, bodyJSON) + if err == nil { + if i > 0 { + s.logger.Info("simulation succeeded on fallback endpoint", "endpointIndex", i) + } + s.metrics.success.Inc() + return logs, isSwap, nil + } + + lastErr = err + + // Only fallback if it's not an application error (e.g., bad request) + if !shouldHTTPFallback(err) { + s.metrics.fail.Inc() + return nil, false, err + } + + s.logger.Warn("endpoint failed, trying fallback", + "endpointIndex", i, + "error", err, + "remainingEndpoints", len(s.apiURLs)-i-1, + ) + } + + s.metrics.fail.Inc() + return nil, false, fmt.Errorf("all endpoints failed: %w", lastErr) +} + +func (s *Simulator) doSimulate(ctx context.Context, apiURL string, bodyJSON []byte) ([]*types.Log, bool, error) { req, err := http.NewRequestWithContext( ctx, http.MethodPost, - fmt.Sprintf("%s/rethsim/simulate/raw", s.apiURL), + fmt.Sprintf("%s/rethsim/simulate/raw", apiURL), strings.NewReader(string(bodyJSON)), ) if err != nil { return nil, false, fmt.Errorf("create request: %w", err) } - s.metrics.attempts.Inc() - req.Header.Set("Content-Type", "application/json") resp, err := s.client.Do(req) if err != nil { - s.metrics.fail.Inc() - return nil, false, fmt.Errorf("do request: %w", err) + return nil, false, err // Network error - will trigger fallback } defer func() { _ = resp.Body.Close() @@ -133,21 +188,31 @@ func (s *Simulator) Simulate(ctx context.Context, txRaw string, state SimState) respBody, err := io.ReadAll(resp.Body) if err != nil { - s.metrics.fail.Inc() - return nil, false, fmt.Errorf("read response: %w", err) + return nil, false, err // Read error - will trigger fallback + } + + // 4xx errors (except 429 rate limit) are client/application errors - don't fallback + if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != http.StatusTooManyRequests { + return nil, false, &NonRetryableError{Err: fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))} } + + // 5xx errors and 429 will trigger fallback (not wrapped in NonRetryableError) if resp.StatusCode != http.StatusOK { - s.metrics.fail.Inc() - return nil, false, fmt.Errorf("bad status %d: %s", resp.StatusCode, string(respBody)) + return nil, false, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) } - logs, isSwap, err := parseResponse(respBody) - if err != nil { - s.metrics.fail.Inc() - return nil, false, err + return parseResponse(respBody) +} + +// shouldHTTPFallback returns true if the error should trigger a fallback to the next endpoint. +// Only NonRetryableError (client errors like bad request, reverts) should NOT trigger fallback. +// Everything else (network errors, 5xx, 429 rate limit) should fallback. +func shouldHTTPFallback(err error) bool { + if err == nil { + return false } - s.metrics.success.Inc() - return logs, isSwap, nil + var appErr *NonRetryableError + return !errors.As(err, &appErr) } func parseResponse(body []byte) ([]*types.Log, bool, error) { @@ -189,19 +254,19 @@ func parseResponse(body []byte) ([]*types.Log, bool, error) { } root := blk.Calls[0] - // Failure → build extended error + // Failure → build extended error (application error - don't fallback) if strings.EqualFold(root.Status, "0x0") { reason := decodeRevert(root.ReturnData, "execution reverted") - return nil, false, fmt.Errorf("reverted: %s", reason) + return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s", reason)} } - // Check trace errors for internal reverts + // Check trace errors for internal reverts (application error - don't fallback) if len(traceErrors) == 0 { traceErrors = blk.TraceErrors } for _, te := range traceErrors { if strings.Contains(strings.ToLower(te), "execution reverted") { - return nil, false, errors.New(te) + return nil, false, &NonRetryableError{Err: errors.New(te)} } } diff --git a/tools/preconf-rpc/sim/simulator_test.go b/tools/preconf-rpc/sim/simulator_test.go index 618b25734..13b99ef18 100644 --- a/tools/preconf-rpc/sim/simulator_test.go +++ b/tools/preconf-rpc/sim/simulator_test.go @@ -55,7 +55,7 @@ func TestSimulator(t *testing.T) { defer srv.Close() t.Logf("Test server running at %s", srv.URL) - simulator := sim.NewSimulator(srv.URL) + simulator := sim.NewSimulator([]string{srv.URL}, nil) t.Run("SuccessfulSimulation1", func(t *testing.T) { result, isSwap, err := simulator.Simulate(context.Background(), "1234", sim.Latest) diff --git a/tools/preconf-rpc/sim/swap_detector.go b/tools/preconf-rpc/sim/swap_detector.go new file mode 100644 index 000000000..c73a5d61b --- /dev/null +++ b/tools/preconf-rpc/sim/swap_detector.go @@ -0,0 +1,60 @@ +package sim + +import ( + "github.com/ethereum/go-ethereum/common" +) + +// Swap event signatures (topic0) used to detect DEX trades. +// These match the signatures used in rethsim. +var swapEventSignatures = map[common.Hash]string{ + // Uniswap V2 Sync - emitted on every swap + common.HexToHash("0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1"): "uniswap_v2_swap", + // Uniswap V3 Swap + common.HexToHash("0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"): "uniswap_v3_swap", + // Uniswap V4 Swap + common.HexToHash("0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f"): "uniswap_v4_swap", + // MetaMask Swap Router + common.HexToHash("0xbeee1e6e7fe307ddcf84b0a16137a4430ad5e2480fc4f4a8e250ab56ccd7630d"): "metamask_swap_router", + // Fluid DEX + common.HexToHash("0xfbce846c23a724e6e61161894819ec46c90a8d3dd96e90e7342c6ef49ffb539c"): "fluid_swap", + // Curve TokenExchange + common.HexToHash("0x56d0661e240dfb199ef196e16e6f42473990366314f0226ac978f7be3cd9ee83"): "curve_finance_swap", + // Curve TokenExchange (tricrypto) + common.HexToHash("0x143f1f8e861fbdeddd5b46e844b7d3ac7b86a122f36e8c463859ee6811b1f29c"): "curve_tricrypto_swap", + // Curve StableSwap NG + common.HexToHash("0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140"): "curve_stableswap_ng_swap", + // Balancer V2 Swap + common.HexToHash("0x2170c741c41531aec20e7c107c24eecfdd15e69c9bb0a8dd37b1840b9e0b207b"): "balancer_swap", + // Balancer LOG_SWAP + common.HexToHash("0x908fb5ee8f16c6bc9bc3690973819f32a4d4b10188134543c88706e0e1d43378"): "balancer_log_swap", + // 1inch Aggregation Router V6 + common.HexToHash("0xfec331350fce78ba658e082a71da20ac9f8d798a99b3c79681c8440cbfe77e07"): "oneinch_aggregation_router_v6", + // SushiSwap Swap (same as Uniswap V2 Swap) + common.HexToHash("0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822"): "sushiswap_swap", + // KyberSwap + common.HexToHash("0xd6d4f5681c246c9f42c203e287975af1601f8df8035a9251f79aab5c8f09e2f8"): "kyberswap_swap", + // PancakeSwap + common.HexToHash("0x19b47279256b2a23a1665c810c8d55a1758940ee09377d4f8d26497a3577dc83"): "pancakeswap_swap", + // DODO + common.HexToHash("0xc2c0245e056d5fb095f04cd6373bc770802ebd1e6c918eb78fdef843cdb37b0f"): "dodoswap_swap", +} + +// DetectSwapsFromLogs scans logs for known swap events. +// Returns true if any swap was detected, along with the list of swap types found. +func DetectSwapsFromLogs(logs []TraceLog) (bool, []string) { + var swapKinds []string + seen := make(map[string]bool) + + for _, log := range logs { + if len(log.Topics) > 0 { + if swapType, ok := swapEventSignatures[log.Topics[0]]; ok { + if !seen[swapType] { + swapKinds = append(swapKinds, swapType) + seen[swapType] = true + } + } + } + } + + return len(swapKinds) > 0, swapKinds +}