From cfe5108d26c3d49a37d4a012cf5a6e5c743756c9 Mon Sep 17 00:00:00 2001 From: kant Date: Mon, 26 Jan 2026 20:14:17 -0800 Subject: [PATCH 1/4] feat: add inline simulation using debug_traceCall - Add InlineSimulator using debug_traceCall with callTracer - Support pending state simulation (required for preconf-rpc) - Add enableReturnData for better revert capture - Port all 15 swap signatures from rethsim - Detect swaps via event topic signatures only - Support multiple RPC endpoints with fallback on 5xx/429/network errors - Add --use-inline-simulation feature flag (default: false) - Include call path (to, type) in inner revert errors --- tools/preconf-rpc/main.go | 17 +- tools/preconf-rpc/service/service.go | 30 +- tools/preconf-rpc/sim/inline_simulator.go | 335 +++++++++++ .../preconf-rpc/sim/inline_simulator_test.go | 521 ++++++++++++++++++ tools/preconf-rpc/sim/simulator.go | 109 +++- tools/preconf-rpc/sim/simulator_test.go | 2 +- tools/preconf-rpc/sim/swap_detector.go | 60 ++ 7 files changed, 1044 insertions(+), 30 deletions(-) create mode 100644 tools/preconf-rpc/sim/inline_simulator.go create mode 100644 tools/preconf-rpc/sim/inline_simulator_test.go create mode 100644 tools/preconf-rpc/sim/swap_detector.go 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..208fd20d7 --- /dev/null +++ b/tools/preconf-rpc/sim/inline_simulator.go @@ -0,0 +1,335 @@ +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 the trace +type TraceLog struct { + Address common.Address `json:"address"` + Topics []common.Hash `json:"topics"` + Data hexutil.Bytes `json:"data"` +} + +// 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"` +} + +// rpcEndpoint holds the RPC client +type rpcEndpoint struct { + client *rpc.Client +} + +// InlineSimulator simulates transactions using debug_traceCall +// Supports multiple RPC endpoints with fallback +type InlineSimulator struct { + endpoints []rpcEndpoint + metrics *metrics + logger *slog.Logger +} + +// NewInlineSimulator creates a new inline simulator with fallback support +// The first URL is the primary endpoint, subsequent URLs are fallbacks +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 { + // Log warning but continue - we'll fail later if all endpoints are down + 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 the simulator +func (s *InlineSimulator) Metrics() []prometheus.Collector { + return []prometheus.Collector{ + s.metrics.attempts, + s.metrics.success, + s.metrics.fail, + s.metrics.latency, + } +} + +// Simulate executes a transaction simulation using debug_traceCall +// Supported states: "latest" and "pending" +// Requires an RPC provider that supports debug_traceCall with pending state +// (e.g., Alchemy, QuickNode, Erigon). This matches rethsim behavior. +// If the primary endpoint fails with a connection error, fallback endpoints are tried. +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() + + // Decode the raw transaction + 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) + } + + // Get sender + 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 for debug_traceCall + // Format: https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debugtracecall + callObj := map[string]interface{}{ + "from": sender.Hex(), + "gas": hexutil.Uint64(tx.Gas()), + "value": hexutil.EncodeBig(tx.Value()), + "data": hexutil.Encode(tx.Data()), + } + if tx.To() != nil { + callObj["to"] = tx.To().Hex() + } + + // Set gas price fields based on transaction type + 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()) + } + + // Execute trace with fallback support + result, err := s.executeTraceWithFallback(ctx, callObj, state) + if err != nil { + s.metrics.fail.Inc() + return nil, false, fmt.Errorf("debug_traceCall failed (state=%s): %w", state, err) + } + + // Check for revert at top level + if result.Error != "" { + s.metrics.fail.Inc() + reason := decodeRevertFromTrace(result.Output, result.Error) + return nil, false, fmt.Errorf("reverted: %s", reason) + } + + // Check for inner call errors (recursive) + if innerErr := findInnerCallError(result); innerErr != "" { + s.metrics.fail.Inc() + return nil, false, fmt.Errorf("inner call reverted: %s", innerErr) + } + + // Collect all logs from trace (depth-first, execution order) + var traceLogs []TraceLog + collectTraceLogs(result, &traceLogs) + + // Validate trace response - a valid trace always has non-zero GasUsed + // (at minimum, intrinsic gas of 21000 is consumed) + gasUsed, err := hexutil.DecodeUint64(result.GasUsed) + if err != nil || gasUsed == 0 { + s.metrics.fail.Inc() + return nil, false, errors.New("empty trace response: missing or zero gas used") + } + + // Detect swaps from logs (same approach as rethsim - topic scanning only) + isSwap, _ := DetectSwapsFromLogs(traceLogs) + + // Convert trace logs to types.Log + logs := convertTraceLogs(traceLogs) + + s.metrics.success.Inc() + return logs, isSwap, nil +} + +// executeTraceWithFallback tries the primary endpoint first, then fallbacks on connection errors +func (s *InlineSimulator) executeTraceWithFallback(ctx context.Context, callObj map[string]interface{}, state SimState) (*TraceCallResult, error) { + var lastErr error + + for i, endpoint := range s.endpoints { + result, err := s.executeTrace(ctx, endpoint.client, callObj, state) + if err == nil { + if i > 0 { + s.logger.Info("simulation succeeded on fallback endpoint", "endpointIndex", i) + } + return result, nil + } + + lastErr = err + + // Only fallback if it's not an application error + if !shouldFallback(err) { + return nil, err + } + + s.logger.Warn("endpoint failed, trying fallback", + "endpointIndex", i, + "error", err, + "remainingEndpoints", len(s.endpoints)-i-1, + ) + } + + return nil, fmt.Errorf("all endpoints failed: %w", lastErr) +} + +// executeTrace calls debug_traceCall with the given parameters +func (s *InlineSimulator) executeTrace(ctx context.Context, client *rpc.Client, callObj map[string]interface{}, state SimState) (*TraceCallResult, error) { + var result TraceCallResult + err := client.CallContext(ctx, &result, "debug_traceCall", + callObj, + string(state), // "latest" or "pending" + map[string]interface{}{ + "tracer": "callTracer", + "tracerConfig": map[string]interface{}{ + "withLog": true, + "enableReturnData": true, + }, + }, + ) + if err != nil { + return nil, err + } + return &result, nil +} + +// shouldFallback returns true if the error should trigger a fallback to the next endpoint. +// JSON-RPC errors (invalid method, invalid params) should NOT trigger fallback. +// HTTP 4xx errors (except 429 rate limit) should NOT trigger fallback. +// Everything else (network errors, 5xx, 429) should fallback. +func shouldFallback(err error) bool { + if err == nil { + return false + } + + // JSON-RPC errors are application-level - don't fallback + // These include "method not found", "invalid params", etc. + var rpcErr rpc.Error + if errors.As(err, &rpcErr) { + return false + } + + // HTTP errors: 4xx (except 429) are client errors - don't fallback + // 5xx and 429 (rate limit) should fallback + var httpErr rpc.HTTPError + if errors.As(err, &httpErr) { + if httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 && httpErr.StatusCode != 429 { + return false + } + return true + } + + // Everything else (network errors, timeouts, etc.) - fallback + return true +} + +// findInnerCallError recursively checks for errors in nested calls +// Returns the error reason with the failing call path (to, type) +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 recursively collects logs from the trace result in depth-first order +// This matches the execution order of events +func collectTraceLogs(call *TraceCallResult, logs *[]TraceLog) { + *logs = append(*logs, call.Logs...) + for i := range call.Calls { + collectTraceLogs(&call.Calls[i], logs) + } +} + +// convertTraceLogs converts TraceLog to types.Log +func convertTraceLogs(traceLogs []TraceLog) []*types.Log { + logs := make([]*types.Log, 0, len(traceLogs)) + for i, tl := range traceLogs { + log := &types.Log{ + Address: tl.Address, + Topics: tl.Topics, + Data: tl.Data, + Index: uint(i), + } + logs = append(logs, log) + } + return logs +} + +// decodeRevertFromTrace attempts to decode a revert reason from trace output +func decodeRevertFromTrace(output string, fallback string) string { + if output == "" || output == "0x" { + return fallback + } + // Try to decode using the existing decodeRevert function + if reason := decodeRevert(output, ""); reason != "" { + return reason + } + return fallback +} + +// Close closes all RPC clients and implements io.Closer +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..def5e1459 --- /dev/null +++ b/tools/preconf-rpc/sim/inline_simulator_test.go @@ -0,0 +1,521 @@ +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 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) { + responses := map[string]string{ + "simple": traceCallResponseSimple, + "swap": traceCallResponseSwap, + "revert": traceCallResponseRevert, + "nestedSwap": traceCallResponseNestedSwap, + "multiHop": traceCallResponseMultiHop, + "curve": traceCallResponseCurve, + "balancer": traceCallResponseBalancer, + } + + srv := 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() }() + + if req.Method != "debug_traceCall" { + http.Error(w, "method not supported", http.StatusBadRequest) + return + } + + // 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": + // Check if there's a value - simple transfer, or check data for revert test + if data, ok := callObj["data"].(string); ok && data == "0xrevert" { + responseKey = "revert" + } else { + responseKey = "simple" + } + case "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": // Uniswap V2 Router + responseKey = "swap" + case "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45": // Uniswap Universal Router + responseKey = "nestedSwap" + case "0x1111111254eeb25477b68fb85ed929f73a960582": // 1inch V5 + responseKey = "multiHop" + case "0x99a58482bd75cbab83b27ec03ca68ff489b5788f": // Curve Router + responseKey = "curve" + case "0x9008d19f58aabd9ed0d60971565aa8510560ab41": // CoW Protocol + responseKey = "balancer" + default: + responseKey = "simple" + } + + response := map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "result": json.RawMessage(responses[responseKey]), + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }), + ) + 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() }() + + // Note: Testing with real signed transactions requires a valid RLP-encoded tx + // The inline simulator tests focus on error handling and the swap detector tests + // cover the swap detection logic + + 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..7066c3555 --- /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 from rethsim (topic0) +// These are the exact signatures used in rethsim/src/main.rs +var swapEventSignatures = map[common.Hash]string{ + // Uniswap V2: Sync(uint112,uint112) - emitted on every swap + common.HexToHash("0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1"): "uniswap_v2_swap", + // Uniswap V3: Swap(address,address,int256,int256,uint160,uint128,int24) + common.HexToHash("0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"): "uniswap_v3_swap", + // Uniswap V4: Swap(PoolId,address,int128,int128,uint160,uint128,int24,uint24) + 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 pools) + common.HexToHash("0x143f1f8e861fbdeddd5b46e844b7d3ac7b86a122f36e8c463859ee6811b1f29c"): "curve_tricrypto_swap", + // Curve: StableSwap NG + common.HexToHash("0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140"): "curve_stableswap_ng_swap", + // Balancer V2: Swap(bytes32,address,address,uint256,uint256) + 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 signature as Uniswap V2 Swap event) + common.HexToHash("0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822"): "sushiswap_swap", + // KyberSwap + common.HexToHash("0xd6d4f5681c246c9f42c203e287975af1601f8df8035a9251f79aab5c8f09e2f8"): "kyberswap_swap", + // PancakeSwap + common.HexToHash("0x19b47279256b2a23a1665c810c8d55a1758940ee09377d4f8d26497a3577dc83"): "pancakeswap_swap", + // DODO + common.HexToHash("0xc2c0245e056d5fb095f04cd6373bc770802ebd1e6c918eb78fdef843cdb37b0f"): "dodoswap_swap", +} + +// DetectSwapsFromLogs checks if logs contain swap events. +// Returns whether a swap was detected and the list of swap kinds 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 +} From b3ecad16cdda630c61544756b4ffc8ed8b2327dc Mon Sep 17 00:00:00 2001 From: kant Date: Mon, 26 Jan 2026 20:36:22 -0800 Subject: [PATCH 2/4] feat: use eth_simulateV1 as primary method with debug_traceCall fallback - Use eth_simulateV1 as the primary simulation method for better performance - Fall back to debug_traceCall when eth_simulateV1 is not supported by the RPC - eth_simulateV1 is lighter and reduces load on RPC providers - debug_traceCall is still used for edge cases or when eth_simulateV1 is unavailable - Add SimulateV1CallResult, SimulateError, SimulateV1Block structs for eth_simulateV1 response - Add isMethodNotSupported() to detect unsupported method errors - Update tests to cover both eth_simulateV1 and fallback scenarios Co-Authored-By: Claude Opus 4.5 --- tools/preconf-rpc/sim/inline_simulator.go | 235 ++++++++++---- .../preconf-rpc/sim/inline_simulator_test.go | 296 +++++++++++++----- 2 files changed, 403 insertions(+), 128 deletions(-) diff --git a/tools/preconf-rpc/sim/inline_simulator.go b/tools/preconf-rpc/sim/inline_simulator.go index 208fd20d7..8ba01aa01 100644 --- a/tools/preconf-rpc/sim/inline_simulator.go +++ b/tools/preconf-rpc/sim/inline_simulator.go @@ -16,13 +16,36 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -// TraceLog represents a log entry from the trace +// TraceLog represents a log entry from simulation (used by both eth_simulateV1 and debug_traceCall) type TraceLog struct { Address common.Address `json:"address"` Topics []common.Hash `json:"topics"` Data hexutil.Bytes `json:"data"` } +// SimulateV1CallResult represents a single 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 from 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"` @@ -43,8 +66,10 @@ type rpcEndpoint struct { client *rpc.Client } -// InlineSimulator simulates transactions using debug_traceCall -// Supports multiple RPC endpoints with fallback +// InlineSimulator simulates transactions using eth_simulateV1 (primary) with debug_traceCall fallback. +// eth_simulateV1 is lighter and preferred for performance, debug_traceCall is used when +// eth_simulateV1 is not supported or for edge cases requiring deeper tracing. +// Supports multiple RPC endpoints with fallback on connection errors. type InlineSimulator struct { endpoints []rpcEndpoint metrics *metrics @@ -96,10 +121,10 @@ func (s *InlineSimulator) Metrics() []prometheus.Collector { } } -// Simulate executes a transaction simulation using debug_traceCall +// Simulate executes a transaction simulation using eth_simulateV1 (primary) or debug_traceCall (fallback). +// eth_simulateV1 is lighter and preferred for performance. +// debug_traceCall is used when eth_simulateV1 is not supported by the RPC. // Supported states: "latest" and "pending" -// Requires an RPC provider that supports debug_traceCall with pending state -// (e.g., Alchemy, QuickNode, Erigon). This matches rethsim behavior. // If the primary endpoint fails with a connection error, fallback endpoints are tried. func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimState) ([]*types.Log, bool, error) { start := time.Now() @@ -130,13 +155,12 @@ func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimS return nil, false, fmt.Errorf("failed to get sender: %w", err) } - // Build call object for debug_traceCall - // Format: https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debugtracecall + // Build call object (used by both eth_simulateV1 and debug_traceCall) callObj := map[string]interface{}{ "from": sender.Hex(), "gas": hexutil.Uint64(tx.Gas()), "value": hexutil.EncodeBig(tx.Value()), - "data": hexutil.Encode(tx.Data()), + "input": hexutil.Encode(tx.Data()), // eth_simulateV1 uses "input", debug_traceCall uses "data" } if tx.To() != nil { callObj["to"] = tx.To().Hex() @@ -151,66 +175,48 @@ func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimS callObj["gasPrice"] = hexutil.EncodeBig(tx.GasPrice()) } - // Execute trace with fallback support - result, err := s.executeTraceWithFallback(ctx, callObj, state) + // Try eth_simulateV1 first (lighter, better performance) + logs, isSwap, err := s.simulateWithFallback(ctx, callObj, state) if err != nil { s.metrics.fail.Inc() - return nil, false, fmt.Errorf("debug_traceCall failed (state=%s): %w", state, err) - } - - // Check for revert at top level - if result.Error != "" { - s.metrics.fail.Inc() - reason := decodeRevertFromTrace(result.Output, result.Error) - return nil, false, fmt.Errorf("reverted: %s", reason) - } - - // Check for inner call errors (recursive) - if innerErr := findInnerCallError(result); innerErr != "" { - s.metrics.fail.Inc() - return nil, false, fmt.Errorf("inner call reverted: %s", innerErr) - } - - // Collect all logs from trace (depth-first, execution order) - var traceLogs []TraceLog - collectTraceLogs(result, &traceLogs) - - // Validate trace response - a valid trace always has non-zero GasUsed - // (at minimum, intrinsic gas of 21000 is consumed) - gasUsed, err := hexutil.DecodeUint64(result.GasUsed) - if err != nil || gasUsed == 0 { - s.metrics.fail.Inc() - return nil, false, errors.New("empty trace response: missing or zero gas used") + return nil, false, err } - // Detect swaps from logs (same approach as rethsim - topic scanning only) - isSwap, _ := DetectSwapsFromLogs(traceLogs) - - // Convert trace logs to types.Log - logs := convertTraceLogs(traceLogs) - s.metrics.success.Inc() return logs, isSwap, nil } -// executeTraceWithFallback tries the primary endpoint first, then fallbacks on connection errors -func (s *InlineSimulator) executeTraceWithFallback(ctx context.Context, callObj map[string]interface{}, state SimState) (*TraceCallResult, error) { +// simulateWithFallback tries eth_simulateV1 first, falls back to debug_traceCall if not supported +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 { - result, err := s.executeTrace(ctx, endpoint.client, callObj, state) + // Try eth_simulateV1 first + 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) + s.logger.Info("simulation succeeded on fallback endpoint", "endpointIndex", i, "method", "eth_simulateV1") + } + return logs, isSwap, nil + } + + // Check if eth_simulateV1 is not supported - fall back to debug_traceCall + if isMethodNotSupported(err) { + s.logger.Debug("eth_simulateV1 not supported, falling back to 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 } - return result, nil } lastErr = err - // Only fallback if it's not an application error + // Only fallback to next endpoint if it's not an application error if !shouldFallback(err) { - return nil, err + return nil, false, err } s.logger.Warn("endpoint failed, trying fallback", @@ -220,15 +226,80 @@ func (s *InlineSimulator) executeTraceWithFallback(ctx context.Context, callObj ) } - return nil, fmt.Errorf("all endpoints failed: %w", lastErr) + return nil, false, fmt.Errorf("all endpoints failed: %w", lastErr) +} + +// executeSimulateV1 calls eth_simulateV1 (lighter, preferred method) +func (s *InlineSimulator) executeSimulateV1(ctx context.Context, client *rpc.Client, callObj map[string]interface{}, state SimState) ([]*types.Log, bool, error) { + // Build eth_simulateV1 request + // Format: https://ethereum.github.io/execution-apis/ethsimulatev1-notes/ + simRequest := map[string]interface{}{ + "blockStateCalls": []map[string]interface{}{ + { + "calls": []map[string]interface{}{callObj}, + }, + }, + "validation": true, + } + + var result []SimulateV1Block + err := client.CallContext(ctx, &result, "eth_simulateV1", simRequest, string(state)) + if err != nil { + return nil, false, err + } + + // Validate response + if len(result) == 0 { + return nil, false, errors.New("empty response from eth_simulateV1") + } + block := result[0] + if len(block.Calls) == 0 { + return nil, false, errors.New("no calls in eth_simulateV1 response") + } + + call := block.Calls[0] + + // Check for revert (status 0x0) + 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, fmt.Errorf("reverted: %s", reason) + } + + // Validate gas used + if call.GasUsed == 0 { + return nil, false, errors.New("empty response: missing or zero gas used") + } + + // Detect swaps from logs + isSwap, _ := DetectSwapsFromLogs(call.Logs) + + // Convert logs to types.Log + logs := convertTraceLogs(call.Logs) + + return logs, isSwap, nil } -// executeTrace calls debug_traceCall with the given parameters -func (s *InlineSimulator) executeTrace(ctx context.Context, client *rpc.Client, callObj map[string]interface{}, state SimState) (*TraceCallResult, error) { +// executeDebugTraceCall calls debug_traceCall (fallback for deeper tracing or unsupported eth_simulateV1) +func (s *InlineSimulator) executeDebugTraceCall(ctx context.Context, client *rpc.Client, callObj map[string]interface{}, state SimState) ([]*types.Log, bool, error) { + // debug_traceCall uses "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", - callObj, - string(state), // "latest" or "pending" + traceCallObj, + string(state), map[string]interface{}{ "tracer": "callTracer", "tracerConfig": map[string]interface{}{ @@ -238,9 +309,59 @@ func (s *InlineSimulator) executeTrace(ctx context.Context, client *rpc.Client, }, ) if err != nil { - return nil, err + return nil, false, fmt.Errorf("debug_traceCall failed (state=%s): %w", state, err) + } + + // Check for revert at top level + if result.Error != "" { + reason := decodeRevertFromTrace(result.Output, result.Error) + return nil, false, fmt.Errorf("reverted: %s", reason) + } + + // Check for inner call errors (recursive) + if innerErr := findInnerCallError(&result); innerErr != "" { + return nil, false, fmt.Errorf("inner call reverted: %s", innerErr) + } + + // Validate trace response - a valid trace always has non-zero GasUsed + gasUsed, err := hexutil.DecodeUint64(result.GasUsed) + if err != nil || gasUsed == 0 { + return nil, false, errors.New("empty trace response: missing or zero gas used") + } + + // Collect all logs from trace (depth-first, execution order) + var traceLogs []TraceLog + collectTraceLogs(&result, &traceLogs) + + // Detect swaps from logs + isSwap, _ := DetectSwapsFromLogs(traceLogs) + + // Convert logs to types.Log + logs := convertTraceLogs(traceLogs) + + return logs, isSwap, nil +} + +// isMethodNotSupported checks if the error indicates the RPC method is not supported +func isMethodNotSupported(err error) bool { + if err == nil { + return false + } + var rpcErr rpc.Error + if errors.As(err, &rpcErr) { + // -32601 is the standard JSON-RPC error code for "Method not found" + // -32600 is "Invalid Request" + code := rpcErr.ErrorCode() + if code == -32601 || code == -32600 { + return true + } + // Also check error message for method not found + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "method not found") || + strings.Contains(msg, "not supported") || + strings.Contains(msg, "unknown method") } - return &result, nil + return false } // shouldFallback returns true if the error should trigger a fallback to the next endpoint. diff --git a/tools/preconf-rpc/sim/inline_simulator_test.go b/tools/preconf-rpc/sim/inline_simulator_test.go index def5e1459..a8f89ef0f 100644 --- a/tools/preconf-rpc/sim/inline_simulator_test.go +++ b/tools/preconf-rpc/sim/inline_simulator_test.go @@ -12,6 +12,54 @@ import ( "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", @@ -208,7 +256,15 @@ var traceCallResponseBalancer = `{ }` func TestInlineSimulator(t *testing.T) { - responses := map[string]string{ + // 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, @@ -218,90 +274,188 @@ func TestInlineSimulator(t *testing.T) { "balancer": traceCallResponseBalancer, } - srv := 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() }() + // 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() }() - if req.Method != "debug_traceCall" { - http.Error(w, "method not supported", http.StatusBadRequest) - return - } + w.Header().Set("Content-Type", "application/json") - // 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 - } + 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 + } - // Route based on the "to" address - to, _ := callObj["to"].(string) - var responseKey string - switch strings.ToLower(to) { - case "0x1234567890123456789012345678901234567890": - // Check if there's a value - simple transfer, or check data for revert test - if data, ok := callObj["data"].(string); ok && data == "0xrevert" { - responseKey = "revert" - } else { - responseKey = "simple" + // 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 } - case "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": // Uniswap V2 Router - responseKey = "swap" - case "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45": // Uniswap Universal Router - responseKey = "nestedSwap" - case "0x1111111254eeb25477b68fb85ed929f73a960582": // 1inch V5 - responseKey = "multiHop" - case "0x99a58482bd75cbab83b27ec03ca68ff489b5788f": // Curve Router - responseKey = "curve" - case "0x9008d19f58aabd9ed0d60971565aa8510560ab41": // CoW Protocol - responseKey = "balancer" - default: - responseKey = "simple" - } - response := map[string]interface{}{ - "jsonrpc": "2.0", - "id": req.ID, - "result": json.RawMessage(responses[responseKey]), - } + 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 + } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - }), - ) - defer srv.Close() + // 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" + } - simulator, err := sim.NewInlineSimulator([]string{srv.URL}, nil) - if err != nil { - t.Fatalf("failed to create inline simulator: %v", err) + 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) + }), + ) } - defer func() { _ = simulator.Close() }() - // Note: Testing with real signed transactions requires a valid RLP-encoded tx - // The inline simulator tests focus on error handling and the swap detector tests - // cover the swap detection logic + // Test with eth_simulateV1 support + t.Run("WithSimulateV1Support", func(t *testing.T) { + srv := createTestServer(true) + defer srv.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") + 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") + } + }) }) - 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") + } + }) }) } From 6c2780afb2bdba995f0e3551cd6b5087bf4e03b8 Mon Sep 17 00:00:00 2001 From: kant Date: Mon, 26 Jan 2026 20:40:16 -0800 Subject: [PATCH 3/4] fix: wrap revert errors in NonRetryableError to prevent unnecessary endpoint fallback Transaction reverts and invalid response errors were not wrapped in NonRetryableError, causing the code to unnecessarily try all fallback endpoints when a transaction reverts. This wasted resources and delayed error responses. Changes: - Wrap revert errors in NonRetryableError in both eth_simulateV1 and debug_traceCall - Wrap empty/invalid response errors in NonRetryableError - Update shouldFallback to check for NonRetryableError first - Add clarifying comments about fallback behavior This ensures reverts fail fast instead of trying all endpoints. Co-Authored-By: Claude Opus 4.5 --- tools/preconf-rpc/sim/inline_simulator.go | 35 ++++++++++++++--------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/tools/preconf-rpc/sim/inline_simulator.go b/tools/preconf-rpc/sim/inline_simulator.go index 8ba01aa01..cbf2ddb2d 100644 --- a/tools/preconf-rpc/sim/inline_simulator.go +++ b/tools/preconf-rpc/sim/inline_simulator.go @@ -214,7 +214,8 @@ func (s *InlineSimulator) simulateWithFallback(ctx context.Context, callObj map[ lastErr = err - // Only fallback to next endpoint if it's not an application error + // Don't fallback on application errors (reverts, bad requests) + // Only fallback on network errors, 5xx, rate limits if !shouldFallback(err) { return nil, false, err } @@ -248,18 +249,18 @@ func (s *InlineSimulator) executeSimulateV1(ctx context.Context, client *rpc.Cli return nil, false, err } - // Validate response + // Validate response - unexpected format, don't retry if len(result) == 0 { - return nil, false, errors.New("empty response from eth_simulateV1") + return nil, false, &NonRetryableError{Err: errors.New("empty response from eth_simulateV1")} } block := result[0] if len(block.Calls) == 0 { - return nil, false, errors.New("no calls in eth_simulateV1 response") + return nil, false, &NonRetryableError{Err: errors.New("no calls in eth_simulateV1 response")} } call := block.Calls[0] - // Check for revert (status 0x0) + // Check for revert (status 0x0) - don't retry on reverts if call.Status == 0 { reason := "execution reverted" if call.Error != nil && call.Error.Message != "" { @@ -267,12 +268,12 @@ func (s *InlineSimulator) executeSimulateV1(ctx context.Context, client *rpc.Cli } else if len(call.ReturnData) > 0 { reason = decodeRevert(hexutil.Encode(call.ReturnData), reason) } - return nil, false, fmt.Errorf("reverted: %s", reason) + return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s", reason)} } - // Validate gas used + // Validate gas used - unexpected response format, don't retry if call.GasUsed == 0 { - return nil, false, errors.New("empty response: missing or zero gas used") + return nil, false, &NonRetryableError{Err: errors.New("empty response: missing or zero gas used")} } // Detect swaps from logs @@ -312,21 +313,21 @@ func (s *InlineSimulator) executeDebugTraceCall(ctx context.Context, client *rpc return nil, false, fmt.Errorf("debug_traceCall failed (state=%s): %w", state, err) } - // Check for revert at top level + // Check for revert at top level - don't retry on reverts if result.Error != "" { reason := decodeRevertFromTrace(result.Output, result.Error) - return nil, false, fmt.Errorf("reverted: %s", reason) + return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s", reason)} } - // Check for inner call errors (recursive) + // Check for inner call errors (recursive) - don't retry on reverts if innerErr := findInnerCallError(&result); innerErr != "" { - return nil, false, fmt.Errorf("inner call reverted: %s", innerErr) + return nil, false, &NonRetryableError{Err: fmt.Errorf("inner call reverted: %s", innerErr)} } // Validate trace response - a valid trace always has non-zero GasUsed gasUsed, err := hexutil.DecodeUint64(result.GasUsed) if err != nil || gasUsed == 0 { - return nil, false, errors.New("empty trace response: missing or zero gas used") + return nil, false, &NonRetryableError{Err: errors.New("empty trace response: missing or zero gas used")} } // Collect all logs from trace (depth-first, execution order) @@ -365,6 +366,7 @@ func isMethodNotSupported(err error) bool { } // shouldFallback returns true if the error should trigger a fallback to the next endpoint. +// NonRetryableError (reverts, bad responses) should NOT trigger fallback. // JSON-RPC errors (invalid method, invalid params) should NOT trigger fallback. // HTTP 4xx errors (except 429 rate limit) should NOT trigger fallback. // Everything else (network errors, 5xx, 429) should fallback. @@ -373,6 +375,13 @@ func shouldFallback(err error) bool { return false } + // NonRetryableError wraps errors that should not trigger fallback + // (e.g., transaction reverts, invalid responses) + var nonRetryable *NonRetryableError + if errors.As(err, &nonRetryable) { + return false + } + // JSON-RPC errors are application-level - don't fallback // These include "method not found", "invalid params", etc. var rpcErr rpc.Error From 3810595c7e0f552e760fb76eb52c556726df7bef Mon Sep 17 00:00:00 2001 From: kant Date: Mon, 26 Jan 2026 20:45:56 -0800 Subject: [PATCH 4/4] refactor: clean up comments for readability - Remove redundant comments that just repeat what the code does - Keep comments where they explain why, not what - Make comments more concise and natural - Simplify code structure in a few places Co-Authored-By: Claude Opus 4.5 --- tools/preconf-rpc/sim/inline_simulator.go | 127 ++++++++-------------- tools/preconf-rpc/sim/swap_detector.go | 26 ++--- 2 files changed, 56 insertions(+), 97 deletions(-) diff --git a/tools/preconf-rpc/sim/inline_simulator.go b/tools/preconf-rpc/sim/inline_simulator.go index cbf2ddb2d..7a8ba9c16 100644 --- a/tools/preconf-rpc/sim/inline_simulator.go +++ b/tools/preconf-rpc/sim/inline_simulator.go @@ -16,14 +16,14 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -// TraceLog represents a log entry from simulation (used by both eth_simulateV1 and debug_traceCall) +// 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 single call result from eth_simulateV1 +// SimulateV1CallResult represents a call result from eth_simulateV1. type SimulateV1CallResult struct { Status hexutil.Uint64 `json:"status"` GasUsed hexutil.Uint64 `json:"gasUsed"` @@ -32,21 +32,21 @@ type SimulateV1CallResult struct { Error *SimulateError `json:"error,omitempty"` } -// SimulateError represents an error from eth_simulateV1 +// 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 +// 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 +// TraceCallResult represents the result of debug_traceCall with callTracer. type TraceCallResult struct { Type string `json:"type"` From string `json:"from"` @@ -61,23 +61,21 @@ type TraceCallResult struct { Logs []TraceLog `json:"logs,omitempty"` } -// rpcEndpoint holds the RPC client type rpcEndpoint struct { client *rpc.Client } -// InlineSimulator simulates transactions using eth_simulateV1 (primary) with debug_traceCall fallback. -// eth_simulateV1 is lighter and preferred for performance, debug_traceCall is used when -// eth_simulateV1 is not supported or for edge cases requiring deeper tracing. -// Supports multiple RPC endpoints with fallback on connection errors. +// 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 new inline simulator with fallback support -// The first URL is the primary endpoint, subsequent URLs are fallbacks +// 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") @@ -87,7 +85,6 @@ func NewInlineSimulator(rpcURLs []string, logger *slog.Logger) (*InlineSimulator for i, url := range rpcURLs { client, err := rpc.Dial(url) if err != nil { - // Log warning but continue - we'll fail later if all endpoints are down if logger != nil { logger.Warn("failed to connect to RPC endpoint", "endpointIndex", i, "error", err) } @@ -111,7 +108,7 @@ func NewInlineSimulator(rpcURLs []string, logger *slog.Logger) (*InlineSimulator }, nil } -// Metrics returns prometheus collectors for the simulator +// Metrics returns prometheus collectors for monitoring. func (s *InlineSimulator) Metrics() []prometheus.Collector { return []prometheus.Collector{ s.metrics.attempts, @@ -121,11 +118,8 @@ func (s *InlineSimulator) Metrics() []prometheus.Collector { } } -// Simulate executes a transaction simulation using eth_simulateV1 (primary) or debug_traceCall (fallback). -// eth_simulateV1 is lighter and preferred for performance. -// debug_traceCall is used when eth_simulateV1 is not supported by the RPC. -// Supported states: "latest" and "pending" -// If the primary endpoint fails with a connection error, fallback endpoints are tried. +// 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() { @@ -134,7 +128,6 @@ func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimS s.metrics.attempts.Inc() - // Decode the raw transaction rawBytes, err := hex.DecodeString(strings.TrimPrefix(txRaw, "0x")) if err != nil { s.metrics.fail.Inc() @@ -147,7 +140,6 @@ func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimS return nil, false, fmt.Errorf("invalid transaction: %w", err) } - // Get sender signer := types.LatestSignerForChainID(tx.ChainId()) sender, err := types.Sender(signer, tx) if err != nil { @@ -155,18 +147,18 @@ func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimS return nil, false, fmt.Errorf("failed to get sender: %w", err) } - // Build call object (used by both eth_simulateV1 and debug_traceCall) + // 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()), // eth_simulateV1 uses "input", debug_traceCall uses "data" + "input": hexutil.Encode(tx.Data()), } if tx.To() != nil { callObj["to"] = tx.To().Hex() } - // Set gas price fields based on transaction type + // 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()) @@ -175,7 +167,6 @@ func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimS callObj["gasPrice"] = hexutil.EncodeBig(tx.GasPrice()) } - // Try eth_simulateV1 first (lighter, better performance) logs, isSwap, err := s.simulateWithFallback(ctx, callObj, state) if err != nil { s.metrics.fail.Inc() @@ -186,12 +177,11 @@ func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimS return logs, isSwap, nil } -// simulateWithFallback tries eth_simulateV1 first, falls back to debug_traceCall if not supported +// 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 { - // Try eth_simulateV1 first logs, isSwap, err := s.executeSimulateV1(ctx, endpoint.client, callObj, state) if err == nil { if i > 0 { @@ -200,9 +190,9 @@ func (s *InlineSimulator) simulateWithFallback(ctx context.Context, callObj map[ return logs, isSwap, nil } - // Check if eth_simulateV1 is not supported - fall back to debug_traceCall + // If eth_simulateV1 isn't supported, try debug_traceCall on the same endpoint if isMethodNotSupported(err) { - s.logger.Debug("eth_simulateV1 not supported, falling back to debug_traceCall", "endpointIndex", i) + 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 { @@ -214,13 +204,13 @@ func (s *InlineSimulator) simulateWithFallback(ctx context.Context, callObj map[ lastErr = err - // Don't fallback on application errors (reverts, bad requests) - // Only fallback on network errors, 5xx, rate limits + // 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 fallback", + s.logger.Warn("endpoint failed, trying next", "endpointIndex", i, "error", err, "remainingEndpoints", len(s.endpoints)-i-1, @@ -230,26 +220,21 @@ func (s *InlineSimulator) simulateWithFallback(ctx context.Context, callObj map[ return nil, false, fmt.Errorf("all endpoints failed: %w", lastErr) } -// executeSimulateV1 calls eth_simulateV1 (lighter, preferred method) +// 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) { - // Build eth_simulateV1 request - // Format: https://ethereum.github.io/execution-apis/ethsimulatev1-notes/ simRequest := map[string]interface{}{ "blockStateCalls": []map[string]interface{}{ - { - "calls": []map[string]interface{}{callObj}, - }, + {"calls": []map[string]interface{}{callObj}}, }, "validation": true, } var result []SimulateV1Block - err := client.CallContext(ctx, &result, "eth_simulateV1", simRequest, string(state)) - if err != nil { + if err := client.CallContext(ctx, &result, "eth_simulateV1", simRequest, string(state)); err != nil { return nil, false, err } - // Validate response - unexpected format, don't retry if len(result) == 0 { return nil, false, &NonRetryableError{Err: errors.New("empty response from eth_simulateV1")} } @@ -260,7 +245,7 @@ func (s *InlineSimulator) executeSimulateV1(ctx context.Context, client *rpc.Cli call := block.Calls[0] - // Check for revert (status 0x0) - don't retry on reverts + // status 0 means reverted if call.Status == 0 { reason := "execution reverted" if call.Error != nil && call.Error.Message != "" { @@ -271,23 +256,19 @@ func (s *InlineSimulator) executeSimulateV1(ctx context.Context, client *rpc.Cli return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s", reason)} } - // Validate gas used - unexpected response format, don't retry if call.GasUsed == 0 { - return nil, false, &NonRetryableError{Err: errors.New("empty response: missing or zero gas used")} + return nil, false, &NonRetryableError{Err: errors.New("invalid response: zero gas used")} } - // Detect swaps from logs isSwap, _ := DetectSwapsFromLogs(call.Logs) - - // Convert logs to types.Log logs := convertTraceLogs(call.Logs) return logs, isSwap, nil } -// executeDebugTraceCall calls debug_traceCall (fallback for deeper tracing or unsupported eth_simulateV1) +// 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 uses "data" instead of "input" + // debug_traceCall expects "data" instead of "input" traceCallObj := make(map[string]interface{}) for k, v := range callObj { if k == "input" { @@ -313,50 +294,42 @@ func (s *InlineSimulator) executeDebugTraceCall(ctx context.Context, client *rpc return nil, false, fmt.Errorf("debug_traceCall failed (state=%s): %w", state, err) } - // Check for revert at top level - don't retry on reverts if result.Error != "" { reason := decodeRevertFromTrace(result.Output, result.Error) return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s", reason)} } - // Check for inner call errors (recursive) - don't retry on reverts + // 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)} } - // Validate trace response - a valid trace always has non-zero GasUsed gasUsed, err := hexutil.DecodeUint64(result.GasUsed) if err != nil || gasUsed == 0 { - return nil, false, &NonRetryableError{Err: errors.New("empty trace response: missing or zero gas used")} + return nil, false, &NonRetryableError{Err: errors.New("invalid trace: zero gas used")} } - // Collect all logs from trace (depth-first, execution order) var traceLogs []TraceLog collectTraceLogs(&result, &traceLogs) - // Detect swaps from logs isSwap, _ := DetectSwapsFromLogs(traceLogs) - - // Convert logs to types.Log logs := convertTraceLogs(traceLogs) return logs, isSwap, nil } -// isMethodNotSupported checks if the error indicates the RPC method is not supported +// 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) { - // -32601 is the standard JSON-RPC error code for "Method not found" - // -32600 is "Invalid Request" code := rpcErr.ErrorCode() + // -32601: Method not found, -32600: Invalid Request if code == -32601 || code == -32600 { return true } - // Also check error message for method not found msg := strings.ToLower(err.Error()) return strings.Contains(msg, "method not found") || strings.Contains(msg, "not supported") || @@ -365,46 +338,37 @@ func isMethodNotSupported(err error) bool { return false } -// shouldFallback returns true if the error should trigger a fallback to the next endpoint. -// NonRetryableError (reverts, bad responses) should NOT trigger fallback. -// JSON-RPC errors (invalid method, invalid params) should NOT trigger fallback. -// HTTP 4xx errors (except 429 rate limit) should NOT trigger fallback. -// Everything else (network errors, 5xx, 429) should fallback. +// 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 } - // NonRetryableError wraps errors that should not trigger fallback - // (e.g., transaction reverts, invalid responses) var nonRetryable *NonRetryableError if errors.As(err, &nonRetryable) { return false } - // JSON-RPC errors are application-level - don't fallback - // These include "method not found", "invalid params", etc. var rpcErr rpc.Error if errors.As(err, &rpcErr) { return false } - // HTTP errors: 4xx (except 429) are client errors - don't fallback - // 5xx and 429 (rate limit) should fallback 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 } - // Everything else (network errors, timeouts, etc.) - fallback return true } -// findInnerCallError recursively checks for errors in nested calls -// Returns the error reason with the failing call path (to, type) +// findInnerCallError recursively searches for errors in nested calls. func findInnerCallError(call *TraceCallResult) string { for i := range call.Calls { if call.Calls[i].Error != "" { @@ -418,8 +382,7 @@ func findInnerCallError(call *TraceCallResult) string { return "" } -// collectTraceLogs recursively collects logs from the trace result in depth-first order -// This matches the execution order of events +// 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 { @@ -427,34 +390,30 @@ func collectTraceLogs(call *TraceCallResult, logs *[]TraceLog) { } } -// convertTraceLogs converts TraceLog to types.Log func convertTraceLogs(traceLogs []TraceLog) []*types.Log { logs := make([]*types.Log, 0, len(traceLogs)) for i, tl := range traceLogs { - log := &types.Log{ + logs = append(logs, &types.Log{ Address: tl.Address, Topics: tl.Topics, Data: tl.Data, Index: uint(i), - } - logs = append(logs, log) + }) } return logs } -// decodeRevertFromTrace attempts to decode a revert reason from trace output func decodeRevertFromTrace(output string, fallback string) string { if output == "" || output == "0x" { return fallback } - // Try to decode using the existing decodeRevert function if reason := decodeRevert(output, ""); reason != "" { return reason } return fallback } -// Close closes all RPC clients and implements io.Closer +// Close releases all RPC connections. func (s *InlineSimulator) Close() error { for _, endpoint := range s.endpoints { if endpoint.client != nil { diff --git a/tools/preconf-rpc/sim/swap_detector.go b/tools/preconf-rpc/sim/swap_detector.go index 7066c3555..c73a5d61b 100644 --- a/tools/preconf-rpc/sim/swap_detector.go +++ b/tools/preconf-rpc/sim/swap_detector.go @@ -4,32 +4,32 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// Swap event signatures from rethsim (topic0) -// These are the exact signatures used in rethsim/src/main.rs +// 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(uint112,uint112) - emitted on every swap + // Uniswap V2 Sync - emitted on every swap common.HexToHash("0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1"): "uniswap_v2_swap", - // Uniswap V3: Swap(address,address,int256,int256,uint160,uint128,int24) + // Uniswap V3 Swap common.HexToHash("0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"): "uniswap_v3_swap", - // Uniswap V4: Swap(PoolId,address,int128,int128,uint160,uint128,int24,uint24) + // 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 + // Curve TokenExchange common.HexToHash("0x56d0661e240dfb199ef196e16e6f42473990366314f0226ac978f7be3cd9ee83"): "curve_finance_swap", - // Curve: TokenExchange (tricrypto pools) + // Curve TokenExchange (tricrypto) common.HexToHash("0x143f1f8e861fbdeddd5b46e844b7d3ac7b86a122f36e8c463859ee6811b1f29c"): "curve_tricrypto_swap", - // Curve: StableSwap NG + // Curve StableSwap NG common.HexToHash("0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140"): "curve_stableswap_ng_swap", - // Balancer V2: Swap(bytes32,address,address,uint256,uint256) + // Balancer V2 Swap common.HexToHash("0x2170c741c41531aec20e7c107c24eecfdd15e69c9bb0a8dd37b1840b9e0b207b"): "balancer_swap", - // Balancer: LOG_SWAP + // Balancer LOG_SWAP common.HexToHash("0x908fb5ee8f16c6bc9bc3690973819f32a4d4b10188134543c88706e0e1d43378"): "balancer_log_swap", // 1inch Aggregation Router V6 common.HexToHash("0xfec331350fce78ba658e082a71da20ac9f8d798a99b3c79681c8440cbfe77e07"): "oneinch_aggregation_router_v6", - // SushiSwap: Swap (same signature as Uniswap V2 Swap event) + // SushiSwap Swap (same as Uniswap V2 Swap) common.HexToHash("0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822"): "sushiswap_swap", // KyberSwap common.HexToHash("0xd6d4f5681c246c9f42c203e287975af1601f8df8035a9251f79aab5c8f09e2f8"): "kyberswap_swap", @@ -39,8 +39,8 @@ var swapEventSignatures = map[common.Hash]string{ common.HexToHash("0xc2c0245e056d5fb095f04cd6373bc770802ebd1e6c918eb78fdef843cdb37b0f"): "dodoswap_swap", } -// DetectSwapsFromLogs checks if logs contain swap events. -// Returns whether a swap was detected and the list of swap kinds found. +// 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)