From 12a662e63a30eadcfa4f6270459c8f92f43a769c Mon Sep 17 00:00:00 2001 From: kant777 <61204489+kant777@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:21:44 -0800 Subject: [PATCH 1/4] feat: add inline simulation using debug_traceCall (#877) * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.5 --- tools/preconf-rpc/main.go | 17 +- tools/preconf-rpc/service/service.go | 30 +- tools/preconf-rpc/sim/inline_simulator.go | 424 +++++++++++ .../preconf-rpc/sim/inline_simulator_test.go | 675 ++++++++++++++++++ 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, 1287 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..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 +} From f9c1e3b04eacc14794a17b591bae86a2b4a4a185 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Thu, 29 Jan 2026 13:23:28 -0500 Subject: [PATCH 2/4] input handling fix --- tools/preconf-rpc/fastswap/fastswap.go | 74 ++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/tools/preconf-rpc/fastswap/fastswap.go b/tools/preconf-rpc/fastswap/fastswap.go index dd2db9cc0..0ac9ccd0c 100644 --- a/tools/preconf-rpc/fastswap/fastswap.go +++ b/tools/preconf-rpc/fastswap/fastswap.go @@ -442,30 +442,86 @@ func (s *Service) Handler() http.HandlerFunc { return } - var req SwapRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + var rawReq struct { + User string `json:"user"` + InputToken string `json:"inputToken"` + OutputToken string `json:"outputToken"` + InputAmt string `json:"inputAmt"` + UserAmtOut string `json:"userAmtOut"` + Recipient string `json:"recipient"` + Deadline string `json:"deadline"` + Nonce string `json:"nonce"` + Signature string `json:"signature"` + } + + if err := json.NewDecoder(r.Body).Decode(&rawReq); err != nil { http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) return } // Validate required fields - if req.User == (common.Address{}) { - http.Error(w, "missing user address", http.StatusBadRequest) + if rawReq.User == "" || !common.IsHexAddress(rawReq.User) { + http.Error(w, "missing or invalid user address", http.StatusBadRequest) return } - if req.InputToken == (common.Address{}) { - http.Error(w, "missing inputToken", http.StatusBadRequest) + if rawReq.InputToken == "" || !common.IsHexAddress(rawReq.InputToken) { + http.Error(w, "missing or invalid inputToken", http.StatusBadRequest) return } - if req.OutputToken == (common.Address{}) { - http.Error(w, "missing outputToken", http.StatusBadRequest) + if rawReq.OutputToken == "" || !common.IsHexAddress(rawReq.OutputToken) { + http.Error(w, "missing or invalid outputToken", http.StatusBadRequest) return } - if len(req.Signature) == 0 { + if rawReq.Recipient == "" || !common.IsHexAddress(rawReq.Recipient) { + http.Error(w, "missing or invalid recipient", http.StatusBadRequest) + return + } + if rawReq.Signature == "" { http.Error(w, "missing signature", http.StatusBadRequest) return } + // Parse big.Int fields + inputAmt, ok := new(big.Int).SetString(rawReq.InputAmt, 10) + if !ok || inputAmt.Sign() <= 0 { + http.Error(w, "invalid inputAmt", http.StatusBadRequest) + return + } + userAmtOut, ok := new(big.Int).SetString(rawReq.UserAmtOut, 10) + if !ok { + http.Error(w, "invalid userAmtOut", http.StatusBadRequest) + return + } + deadline, ok := new(big.Int).SetString(rawReq.Deadline, 10) + if !ok || deadline.Sign() <= 0 { + http.Error(w, "invalid deadline", http.StatusBadRequest) + return + } + nonce, ok := new(big.Int).SetString(rawReq.Nonce, 10) + if !ok { + http.Error(w, "invalid nonce", http.StatusBadRequest) + return + } + + // Decode signature from hex + signature, err := hex.DecodeString(strings.TrimPrefix(rawReq.Signature, "0x")) + if err != nil { + http.Error(w, "invalid signature hex", http.StatusBadRequest) + return + } + + req := SwapRequest{ + User: common.HexToAddress(rawReq.User), + InputToken: common.HexToAddress(rawReq.InputToken), + OutputToken: common.HexToAddress(rawReq.OutputToken), + InputAmt: inputAmt, + UserAmtOut: userAmtOut, + Recipient: common.HexToAddress(rawReq.Recipient), + Deadline: deadline, + Nonce: nonce, + Signature: signature, + } + result, err := s.HandleSwap(r.Context(), req) if err != nil { http.Error(w, fmt.Sprintf("swap failed: %v", err), http.StatusInternalServerError) From aebcf3f3b35f67bb3ba8263e928e577db06ab832 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Thu, 29 Jan 2026 13:56:16 -0500 Subject: [PATCH 3/4] test fix --- tools/preconf-rpc/fastswap/fastswap_test.go | 33 ++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/tools/preconf-rpc/fastswap/fastswap_test.go b/tools/preconf-rpc/fastswap/fastswap_test.go index 4dd761b2f..e57f800c9 100644 --- a/tools/preconf-rpc/fastswap/fastswap_test.go +++ b/tools/preconf-rpc/fastswap/fastswap_test.go @@ -391,17 +391,17 @@ func TestHandler(t *testing.T) { handler := svc.Handler() - // Use raw JSON with flattened request structure + // Use raw JSON with string values for numeric fields (new handler format) reqJSON := `{ "user": "0x0000000000000000000000000000000000000001", "inputToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "outputToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "inputAmt": 1000000000, - "userAmtOut": 500000000000000000, + "inputAmt": "1000000000", + "userAmtOut": "500000000000000000", "recipient": "0x0000000000000000000000000000000000000002", - "deadline": 1700000000, - "nonce": 1, - "signature": "AQIDBA==" + "deadline": "1700000000", + "nonce": "1", + "signature": "0x01020304" }` req := httptest.NewRequest(http.MethodPost, "/fastswap", strings.NewReader(reqJSON)) @@ -444,14 +444,14 @@ func TestHandler_MissingFields(t *testing.T) { { name: "missing user", reqBody: map[string]interface{}{}, - expected: "missing user address", + expected: "missing or invalid user address", }, { name: "missing inputToken", reqBody: map[string]interface{}{ "user": "0x1234567890123456789012345678901234567890", }, - expected: "missing inputToken", + expected: "missing or invalid inputToken", }, { name: "missing outputToken", @@ -459,7 +459,16 @@ func TestHandler_MissingFields(t *testing.T) { "user": "0x1234567890123456789012345678901234567890", "inputToken": "0x1234567890123456789012345678901234567890", }, - expected: "missing outputToken", + expected: "missing or invalid outputToken", + }, + { + name: "missing recipient", + reqBody: map[string]interface{}{ + "user": "0x1234567890123456789012345678901234567890", + "inputToken": "0x1234567890123456789012345678901234567890", + "outputToken": "0x1234567890123456789012345678901234567890", + }, + expected: "missing or invalid recipient", }, { name: "missing signature", @@ -467,7 +476,11 @@ func TestHandler_MissingFields(t *testing.T) { "user": "0x1234567890123456789012345678901234567890", "inputToken": "0x1234567890123456789012345678901234567890", "outputToken": "0x1234567890123456789012345678901234567890", - "inputAmt": 1000, + "recipient": "0x1234567890123456789012345678901234567890", + "inputAmt": "1000", + "userAmtOut": "900", + "deadline": "1700000000", + "nonce": "0", }, expected: "missing signature", }, From bdc99a9314c77a2f21ff37ffa63b8f95b5d64e6b Mon Sep 17 00:00:00 2001 From: owen-eth Date: Sun, 1 Feb 2026 13:29:23 -0500 Subject: [PATCH 4/4] fix: fresh wallet nonce handling, skip balance check for fastswap txns --- tools/preconf-rpc/fastswap/fastswap.go | 32 ++++++++++++++--- tools/preconf-rpc/fastswap/fastswap_test.go | 16 +++++++-- tools/preconf-rpc/handlers/handlers.go | 33 ++++++++++++------ tools/preconf-rpc/sender/sender.go | 38 ++++++++++++++++----- tools/preconf-rpc/sender/sender_test.go | 14 ++++---- tools/preconf-rpc/service/service.go | 2 +- tools/preconf-rpc/store/store.go | 21 ++++++------ tools/preconf-rpc/store/store_test.go | 5 ++- 8 files changed, 116 insertions(+), 45 deletions(-) diff --git a/tools/preconf-rpc/fastswap/fastswap.go b/tools/preconf-rpc/fastswap/fastswap.go index 0ac9ccd0c..a80a58bbf 100644 --- a/tools/preconf-rpc/fastswap/fastswap.go +++ b/tools/preconf-rpc/fastswap/fastswap.go @@ -152,6 +152,11 @@ type BlockTracker interface { NextBaseFee() *big.Int } +// NonceStore interface for getting the current nonce from internal store +type NonceStore interface { + GetCurrentNonce(ctx context.Context, sender common.Address) (uint64, bool) +} + // Service handles FastSwap operations. type Service struct { barterBaseURL string @@ -164,6 +169,7 @@ type Service struct { signer Signer txEnqueuer TxEnqueuer blockTracker BlockTracker + nonceStore NonceStore } // NewService creates a new FastSwap service. @@ -189,10 +195,11 @@ func NewService( // SetExecutorDeps sets the dependencies needed for Path 1 executor transaction submission. // This is called after TxSender is created since there's a circular dependency. -func (s *Service) SetExecutorDeps(signer Signer, txEnqueuer TxEnqueuer, blockTracker BlockTracker) { +func (s *Service) SetExecutorDeps(signer Signer, txEnqueuer TxEnqueuer, blockTracker BlockTracker, nonceStore NonceStore) { s.signer = signer s.txEnqueuer = txEnqueuer s.blockTracker = blockTracker + s.nonceStore = nonceStore } // ============ Barter API ============ @@ -347,15 +354,30 @@ func (s *Service) HandleSwap(ctx context.Context, req SwapRequest) (*SwapResult, gasLimit += 100000 // Buffer for settlement contract overhead // 4. Get nonce for executor wallet + // Use same logic as sender's hasCorrectNonce executorAddr := s.signer.GetAddress() - nonce, err := s.blockTracker.AccountNonce(ctx, executorAddr) + maxNonce, hasTxs := s.nonceStore.GetCurrentNonce(ctx, executorAddr) + chainNonce, err := s.blockTracker.AccountNonce(ctx, executorAddr) if err != nil { return &SwapResult{ Status: "error", - Error: fmt.Sprintf("failed to get nonce: %v", err), + Error: fmt.Sprintf("failed to get chain nonce: %v", err), }, nil } + var nonce uint64 + if hasTxs { + // Has transactions in store, next nonce is max + 1 + nonce = maxNonce + 1 + } else { + // No transactions in store, use chain nonce + nonce = chainNonce + } + // If chain has advanced beyond our tracking, use chain nonce + if chainNonce > nonce { + nonce = chainNonce + } + // 5. Calculate gas pricing: GasFeeCap = NextBaseFee only (no tip needed, mev-commit bid handles inclusion) nextBaseFee := s.blockTracker.NextBaseFee() if nextBaseFee == nil || nextBaseFee.Sign() == 0 { @@ -396,12 +418,12 @@ func (s *Service) HandleSwap(ctx context.Context, req SwapRequest) (*SwapResult, } rawTxHex := "0x" + hex.EncodeToString(rawTxBytes) - // 9. Enqueue the transaction + // 9. Enqueue the transaction (uses TxTypeFastSwap to skip balance check) senderTx := &sender.Transaction{ Transaction: signedTx, Sender: executorAddr, Raw: rawTxHex, - Type: sender.TxTypeRegular, + Type: sender.TxTypeFastSwap, } if err := s.txEnqueuer.Enqueue(ctx, senderTx); err != nil { diff --git a/tools/preconf-rpc/fastswap/fastswap_test.go b/tools/preconf-rpc/fastswap/fastswap_test.go index e57f800c9..9b587981d 100644 --- a/tools/preconf-rpc/fastswap/fastswap_test.go +++ b/tools/preconf-rpc/fastswap/fastswap_test.go @@ -57,6 +57,16 @@ func (m *mockBlockTracker) NextBaseFee() *big.Int { return m.nextBaseFee } +// mockNonceStore implements fastswap.NonceStore interface +type mockNonceStore struct { + nonce uint64 + hasTxs bool +} + +func (m *mockNonceStore) GetCurrentNonce(_ context.Context, _ common.Address) (uint64, bool) { + return m.nonce, m.hasTxs +} + // ============ Test Helpers ============ func newTestBarterResponse() fastswap.BarterResponse { @@ -274,7 +284,8 @@ func TestHandleSwap(t *testing.T) { nonce: 5, nextBaseFee: big.NewInt(30000000000), // 30 gwei } - svc.SetExecutorDeps(mockSigner, mockEnqueuer, mockTracker) + mockStore := &mockNonceStore{nonce: 4, hasTxs: true} // store nonce + 1 should match tracker nonce + svc.SetExecutorDeps(mockSigner, mockEnqueuer, mockTracker, mockStore) req := fastswap.SwapRequest{ User: common.HexToAddress("0xUserAddress"), @@ -387,7 +398,8 @@ func TestHandler(t *testing.T) { nonce: 0, nextBaseFee: big.NewInt(30000000000), } - svc.SetExecutorDeps(mockSignerInst, mockEnqueuer, mockTracker) + mockStore := &mockNonceStore{nonce: 0, hasTxs: false} + svc.SetExecutorDeps(mockSignerInst, mockEnqueuer, mockTracker, mockStore) handler := svc.Handler() diff --git a/tools/preconf-rpc/handlers/handlers.go b/tools/preconf-rpc/handlers/handlers.go index b2f510029..b9c38c52c 100644 --- a/tools/preconf-rpc/handlers/handlers.go +++ b/tools/preconf-rpc/handlers/handlers.go @@ -37,7 +37,7 @@ type Store interface { GetTransactionCommitments(ctx context.Context, txnHash common.Hash) ([]*bidderapiv1.Commitment, error) GetTransactionLogs(ctx context.Context, txnHash common.Hash) ([]*types.Log, error) GetBalance(ctx context.Context, account common.Address) (*big.Int, error) - GetCurrentNonce(ctx context.Context, account common.Address) uint64 + GetCurrentNonce(ctx context.Context, account common.Address) (uint64, bool) HasBalance(ctx context.Context, account common.Address, amount *big.Int) bool AlreadySubsidized(ctx context.Context, account common.Address) bool AddSubsidy(ctx context.Context, account common.Address, amount *big.Int) error @@ -687,18 +687,31 @@ func (h *rpcMethodHandler) handleGetTxCount(ctx context.Context, params ...any) ) } - accNonce := h.store.GetCurrentNonce(ctx, common.HexToAddress(account)) - if accNonce == 0 { - return nil, true, nil - } - - accNonce += 1 + maxNonce, hasTxs := h.store.GetCurrentNonce(ctx, common.HexToAddress(account)) + // Get backend nonce backendNonce, err := h.blockTracker.AccountNonce(ctx, common.HexToAddress(account)) - if err == nil { - if backendNonce > accNonce { - accNonce = backendNonce + if err != nil { + // If backend fails and no store txs, return nil (proxy will handle) + if !hasTxs { + return nil, true, nil } + // Otherwise use store nonce + 1 + backendNonce = 0 + } + + var accNonce uint64 + if hasTxs { + // Has transactions in store, next nonce is max + 1 + accNonce = maxNonce + 1 + } else { + // No transactions in store, use chain nonce + accNonce = backendNonce + } + + // If chain has advanced beyond our tracking, use chain nonce + if backendNonce > accNonce { + accNonce = backendNonce } nonceJSON, err := json.Marshal(accNonce) diff --git a/tools/preconf-rpc/sender/sender.go b/tools/preconf-rpc/sender/sender.go index 9cb83cbed..700dcd10a 100644 --- a/tools/preconf-rpc/sender/sender.go +++ b/tools/preconf-rpc/sender/sender.go @@ -27,6 +27,7 @@ const ( TxTypeRegular TxType = iota TxTypeDeposit TxTypeInstantBridge + TxTypeFastSwap // Executor-submitted fastswap transactions (skip balance check) ) type TxStatus string @@ -104,7 +105,7 @@ func effectiveFeePerGas(tx *types.Transaction) *big.Int { type Store interface { AddQueuedTransaction(ctx context.Context, tx *Transaction) error GetQueuedTransactions(ctx context.Context) ([]*Transaction, error) - GetCurrentNonce(ctx context.Context, sender common.Address) uint64 + GetCurrentNonce(ctx context.Context, sender common.Address) (uint64, bool) HasBalance(ctx context.Context, sender common.Address, amount *big.Int) bool AddBalance(ctx context.Context, account common.Address, amount *big.Int) error DeductBalance(ctx context.Context, account common.Address, amount *big.Int) error @@ -271,7 +272,7 @@ func validateTransaction(tx *Transaction) error { if tx == nil || tx.Transaction == nil { return ErrInvalidTransaction } - if tx.Type < TxTypeRegular || tx.Type > TxTypeInstantBridge { + if tx.Type < TxTypeRegular || tx.Type > TxTypeFastSwap { return ErrUnsupportedTxType } if tx.Raw == "" { @@ -290,17 +291,33 @@ func validateTransaction(tx *Transaction) error { } func (t *TxSender) hasCorrectNonce(ctx context.Context, tx *Transaction) error { - currentNonce := t.store.GetCurrentNonce(ctx, tx.Sender) + 1 + // Get backend (chain) nonce first backendNonce, err := t.blockTracker.AccountNonce(ctx, tx.Sender) - if err == nil { - if backendNonce > currentNonce { - currentNonce = backendNonce - } + if err != nil { + return fmt.Errorf("failed to get backend nonce: %w", err) + } + + // Get store nonce - returns (maxNonce, hasTxs) + maxNonce, hasTxs := t.store.GetCurrentNonce(ctx, tx.Sender) + + var expectedNonce uint64 + if hasTxs { + // Has transactions in store, next nonce is max + 1 + expectedNonce = maxNonce + 1 + } else { + // No transactions in store, use chain nonce + expectedNonce = backendNonce + } + + // If chain has advanced beyond our tracking, use chain nonce + if backendNonce > expectedNonce { + expectedNonce = backendNonce } + switch { - case tx.Nonce() < currentNonce: + case tx.Nonce() < expectedNonce: return ErrNonceTooLow - case tx.Nonce() > currentNonce: + case tx.Nonce() > expectedNonce: return ErrNonceTooHigh } @@ -847,6 +864,9 @@ func (t *TxSender) sendBid( ) } slashAmount = new(big.Int).Set(txn.Value()) + case TxTypeFastSwap: + // FastSwap executor transactions skip balance check - RPC bidder pays for bids + logger.Debug("FastSwap transaction - skipping balance check", "sender", txn.Sender.Hex()) } state := sim.Latest diff --git a/tools/preconf-rpc/sender/sender_test.go b/tools/preconf-rpc/sender/sender_test.go index 627bbfa2f..71fad15cc 100644 --- a/tools/preconf-rpc/sender/sender_test.go +++ b/tools/preconf-rpc/sender/sender_test.go @@ -70,16 +70,16 @@ func (m *mockStore) GetQueuedTransactions(_ context.Context) ([]*sender.Transact return txns, nil } -func (m *mockStore) GetCurrentNonce(_ context.Context, sender common.Address) uint64 { +func (m *mockStore) GetCurrentNonce(_ context.Context, sender common.Address) (uint64, bool) { m.mu.Lock() defer m.mu.Unlock() nonce, exists := m.nonce[sender] if !exists { - return 0 + return 0, false } - return nonce + return nonce, true } func (m *mockStore) HasBalance(ctx context.Context, sender common.Address, amount *big.Int) bool { @@ -349,7 +349,7 @@ func TestSender(t *testing.T) { tx1 := &sender.Transaction{ Transaction: types.NewTransaction( - 1, + 0, common.HexToAddress("0x1234567890123456789012345678901234567890"), big.NewInt(100), 21000, @@ -451,7 +451,7 @@ func TestSender(t *testing.T) { tx2 := &sender.Transaction{ Transaction: types.NewTransaction( - 2, + 1, common.HexToAddress("0x1234567890123456789012345678901234567890"), big.NewInt(1e18), 21000, @@ -604,7 +604,7 @@ func TestCancelTransaction(t *testing.T) { tx1 := &sender.Transaction{ Transaction: types.NewTransaction( - 1, + 0, common.HexToAddress("0x1234567890123456789012345678901234567890"), big.NewInt(100), 21000, @@ -693,7 +693,7 @@ func TestIgnoreProvidersOnRetry(t *testing.T) { tx1 := &sender.Transaction{ Transaction: types.NewTransaction( - 1, + 0, common.HexToAddress("0x1234567890123456789012345678901234567890"), big.NewInt(100), 21000, diff --git a/tools/preconf-rpc/service/service.go b/tools/preconf-rpc/service/service.go index 66c4f6789..61bb84f9d 100644 --- a/tools/preconf-rpc/service/service.go +++ b/tools/preconf-rpc/service/service.go @@ -456,7 +456,7 @@ func New(config *Config) (*Service, error) { // Wire executor dependencies for Path 1 (executor-submitted transactions) // Uses separate FastSwapSigner to isolate from main operational wallet if config.FastSwapSigner != nil { - fastswapSvc.SetExecutorDeps(config.FastSwapSigner, sndr, blockTracker) + fastswapSvc.SetExecutorDeps(config.FastSwapSigner, sndr, blockTracker, rpcstore) config.Logger.Info("FastSwap Path 1 enabled", "executorAddress", config.FastSwapSigner.GetAddress().Hex(), ) diff --git a/tools/preconf-rpc/store/store.go b/tools/preconf-rpc/store/store.go index 5af6fa826..ea4dd9295 100644 --- a/tools/preconf-rpc/store/store.go +++ b/tools/preconf-rpc/store/store.go @@ -476,23 +476,24 @@ func (s *rpcstore) GetTransactionCommitments(ctx context.Context, txnHash common return commitments, nil } -// GetCurrentNonce retrieves the next nonce for a given sender address by looking at the -// pending transactions in the database. If there are no pending transactions, it returns 0. -// The RPC would proxy this call to the underlying Ethereum node to get the current nonce in -// case if 0 is returned. -func (s *rpcstore) GetCurrentNonce(ctx context.Context, sender common.Address) uint64 { +// GetCurrentNonce retrieves the max nonce for a given sender address by looking at the +// non-failed transactions in the database. Returns (maxNonce, true) if transactions exist, +// or (0, false) if no transactions found. The caller should use the chain nonce when +// hasTxs is false. +func (s *rpcstore) GetCurrentNonce(ctx context.Context, sender common.Address) (uint64, bool) { query := ` - SELECT COALESCE(MAX(nonce), 0) + SELECT COALESCE(MAX(nonce), 0), COUNT(*) > 0 FROM mcTransactions WHERE sender = $1 AND status != 'failed'; ` row := s.db.QueryRowContext(ctx, query, sender.Hex()) - var nextNonce uint64 - err := row.Scan(&nextNonce) + var maxNonce uint64 + var hasTxs bool + err := row.Scan(&maxNonce, &hasTxs) if err != nil { - return 0 // If no pending transactions found, return 0 as the next nonce + return 0, false // If query fails, assume no transactions } - return nextNonce + return maxNonce, hasTxs } func (s *rpcstore) DeductBalance( diff --git a/tools/preconf-rpc/store/store_test.go b/tools/preconf-rpc/store/store_test.go index 31963d5fd..818852ee9 100644 --- a/tools/preconf-rpc/store/store_test.go +++ b/tools/preconf-rpc/store/store_test.go @@ -181,7 +181,10 @@ func TestStore(t *testing.T) { t.Run("GetCurrentNonce", func(t *testing.T) { senderAddress := common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd") - nonce := st.GetCurrentNonce(context.Background(), senderAddress) + nonce, hasTxs := st.GetCurrentNonce(context.Background(), senderAddress) + if !hasTxs { + t.Fatalf("expected hasTxs to be true, got false") + } if nonce != 1 { t.Fatalf("expected nonce 1, got %d", nonce) }