Skip to content

Commit df72606

Browse files
authored
fix: add call path to revert errors and improve sender recovery (#880)
- Include target address in revert error messages for both eth_simulateV1 and debug_traceCall to help identify which call failed - Handle contract creation case consistently (show "contract creation" when to address is empty) - Add recoverSender function that handles different tx types correctly: pre-EIP-155 (HomesteadSigner), EIP-155, EIP-2930, EIP-1559, EIP-4844 - Add tests with real mainnet transactions (pre-EIP-155 and EIP-1559) to verify full simulation pipeline end-to-end
1 parent 6c7a10b commit df72606

2 files changed

Lines changed: 226 additions & 13 deletions

File tree

tools/preconf-rpc/sim/inline_simulator.go

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func NewInlineSimulator(rpcURLs []string, logger *slog.Logger) (*InlineSimulator
9494
}
9595

9696
if len(endpoints) == 0 {
97-
return nil, fmt.Errorf("failed to connect to any RPC endpoint")
97+
return nil, errors.New("failed to connect to any RPC endpoint")
9898
}
9999

100100
if logger == nil {
@@ -140,11 +140,10 @@ func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimS
140140
return nil, false, fmt.Errorf("invalid transaction: %w", err)
141141
}
142142

143-
signer := types.LatestSignerForChainID(tx.ChainId())
144-
sender, err := types.Sender(signer, tx)
143+
sender, err := recoverSender(tx)
145144
if err != nil {
146145
s.metrics.fail.Inc()
147-
return nil, false, fmt.Errorf("failed to get sender: %w", err)
146+
return nil, false, fmt.Errorf("failed to recover sender: %w", err)
148147
}
149148

150149
// Build call object. We use "input" here; debug_traceCall expects "data" so we convert later.
@@ -158,7 +157,6 @@ func (s *InlineSimulator) Simulate(ctx context.Context, txRaw string, state SimS
158157
callObj["to"] = tx.To().Hex()
159158
}
160159

161-
// Set gas price fields based on tx type (EIP-1559 vs legacy)
162160
switch tx.Type() {
163161
case types.DynamicFeeTxType, types.BlobTxType:
164162
callObj["maxFeePerGas"] = hexutil.EncodeBig(tx.GasFeeCap())
@@ -245,6 +243,12 @@ func (s *InlineSimulator) executeSimulateV1(ctx context.Context, client *rpc.Cli
245243

246244
call := block.Calls[0]
247245

246+
// Extract call target for error messages
247+
toAddr := "contract creation"
248+
if to, ok := callObj["to"].(string); ok && to != "" {
249+
toAddr = to
250+
}
251+
248252
// status 0 means reverted
249253
if call.Status == 0 {
250254
reason := "execution reverted"
@@ -253,7 +257,7 @@ func (s *InlineSimulator) executeSimulateV1(ctx context.Context, client *rpc.Cli
253257
} else if len(call.ReturnData) > 0 {
254258
reason = decodeRevert(hexutil.Encode(call.ReturnData), reason)
255259
}
256-
return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s", reason)}
260+
return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s (to=%s)", reason, toAddr)}
257261
}
258262

259263
if call.GasUsed == 0 {
@@ -296,7 +300,11 @@ func (s *InlineSimulator) executeDebugTraceCall(ctx context.Context, client *rpc
296300

297301
if result.Error != "" {
298302
reason := decodeRevertFromTrace(result.Output, result.Error)
299-
return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s", reason)}
303+
toAddr := result.To
304+
if toAddr == "" {
305+
toAddr = "contract creation"
306+
}
307+
return nil, false, &NonRetryableError{Err: fmt.Errorf("reverted: %s (to=%s)", reason, toAddr)}
300308
}
301309

302310
// Check nested calls for reverts (e.g., inner contract call failed)
@@ -422,3 +430,30 @@ func (s *InlineSimulator) Close() error {
422430
}
423431
return nil
424432
}
433+
434+
// recoverSender extracts the sender address from a signed transaction.
435+
// Uses the appropriate signer based on transaction type to handle edge cases
436+
// like pre-EIP-155 transactions that lack chain ID replay protection.
437+
func recoverSender(tx *types.Transaction) (common.Address, error) {
438+
var signer types.Signer
439+
440+
switch tx.Type() {
441+
case types.LegacyTxType:
442+
chainID := tx.ChainId()
443+
if chainID.Sign() == 0 {
444+
signer = types.HomesteadSigner{}
445+
} else {
446+
signer = types.NewEIP155Signer(chainID)
447+
}
448+
case types.AccessListTxType:
449+
signer = types.NewEIP2930Signer(tx.ChainId())
450+
case types.DynamicFeeTxType:
451+
signer = types.NewLondonSigner(tx.ChainId())
452+
case types.BlobTxType:
453+
signer = types.NewCancunSigner(tx.ChainId())
454+
default:
455+
signer = types.LatestSignerForChainID(tx.ChainId())
456+
}
457+
458+
return types.Sender(signer, tx)
459+
}

tools/preconf-rpc/sim/inline_simulator_test.go

Lines changed: 184 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package sim_test
22

33
import (
44
"context"
5+
"encoding/hex"
56
"encoding/json"
67
"net/http"
78
"net/http/httptest"
89
"strings"
910
"testing"
1011

1112
"github.com/ethereum/go-ethereum/common"
13+
"github.com/ethereum/go-ethereum/core/types"
1214
"github.com/primev/mev-commit/tools/preconf-rpc/sim"
1315
)
1416

@@ -255,6 +257,188 @@ var traceCallResponseBalancer = `{
255257
]
256258
}`
257259

260+
// Real mainnet transaction test vectors for e2e validation of parsing and sender recovery.
261+
var realTxVectors = []struct {
262+
name string
263+
rawHex string
264+
expectedType uint8
265+
expectedSender string
266+
hasChainID bool
267+
}{
268+
{
269+
// Pre-EIP-155 legacy transaction (no chain ID replay protection)
270+
// Block 46147 - early mainnet transaction
271+
name: "PreEIP155_Legacy",
272+
rawHex: "f86780862d79883d2000825208945df9b87991262f6ba471f09758cde1c0fc1de734827a69801ca088ff6cf0fefd94db46111149ae4bfc179e9b94721fffd821d38d16464b3f71d0a045e0aff800961cfce805daef7016f9ae479c0a24afba38dd33c2ecdbb01dcacf",
273+
expectedType: types.LegacyTxType,
274+
expectedSender: "0xD3678D173368032b34E00AE057C31b083FBAb830",
275+
hasChainID: false,
276+
},
277+
{
278+
// EIP-1559 dynamic fee transaction (type 2)
279+
name: "EIP1559_DynamicFee",
280+
rawHex: "02f8730101843b9aca00850c92a69c0082520894d8da6bf26964af9d7eed9e03e53415d37aa9604588016345785d8a000080c001a0a9f0aabbfa2b831dd37d0f8d48d941f35f4fd40a1f2e2fa74a7df3e60aa534c8a0488e799fae157d086b8e0b624ab63627f14509482fe037e88f516a3725070896",
281+
expectedType: types.DynamicFeeTxType,
282+
expectedSender: "0xcEC000D467698070C6D8D73D8ff1F60FD7DCb531",
283+
hasChainID: true,
284+
},
285+
}
286+
287+
func TestTransactionParsingAndSenderRecovery(t *testing.T) {
288+
for _, tc := range realTxVectors {
289+
t.Run(tc.name, func(t *testing.T) {
290+
rawBytes, err := hex.DecodeString(tc.rawHex)
291+
if err != nil {
292+
t.Fatalf("failed to decode hex: %v", err)
293+
}
294+
295+
tx := new(types.Transaction)
296+
if err := tx.UnmarshalBinary(rawBytes); err != nil {
297+
t.Fatalf("failed to parse tx: %v", err)
298+
}
299+
300+
if tx.Type() != tc.expectedType {
301+
t.Errorf("expected tx type %d, got %d", tc.expectedType, tx.Type())
302+
}
303+
304+
if tc.hasChainID {
305+
if tx.ChainId().Sign() == 0 {
306+
t.Error("expected non-zero chainId")
307+
}
308+
} else {
309+
if tx.ChainId().Sign() != 0 {
310+
t.Errorf("expected chainId 0, got %s", tx.ChainId().String())
311+
}
312+
}
313+
314+
sender, err := recoverSenderForTest(tx)
315+
if err != nil {
316+
t.Fatalf("failed to recover sender: %v", err)
317+
}
318+
319+
if sender == (common.Address{}) {
320+
t.Error("recovered zero address")
321+
}
322+
323+
if tc.expectedSender != "" {
324+
expected := common.HexToAddress(tc.expectedSender)
325+
if sender != expected {
326+
t.Errorf("sender mismatch: got %s, want %s", sender.Hex(), expected.Hex())
327+
}
328+
}
329+
330+
t.Logf("tx=%s sender=%s chainId=%s", tc.name, sender.Hex(), tx.ChainId().String())
331+
})
332+
}
333+
}
334+
335+
func recoverSenderForTest(tx *types.Transaction) (common.Address, error) {
336+
var signer types.Signer
337+
switch tx.Type() {
338+
case types.LegacyTxType:
339+
if tx.ChainId().Sign() == 0 {
340+
signer = types.HomesteadSigner{}
341+
} else {
342+
signer = types.NewEIP155Signer(tx.ChainId())
343+
}
344+
case types.AccessListTxType:
345+
signer = types.NewEIP2930Signer(tx.ChainId())
346+
case types.DynamicFeeTxType:
347+
signer = types.NewLondonSigner(tx.ChainId())
348+
case types.BlobTxType:
349+
signer = types.NewCancunSigner(tx.ChainId())
350+
default:
351+
signer = types.LatestSignerForChainID(tx.ChainId())
352+
}
353+
return types.Sender(signer, tx)
354+
}
355+
356+
// TestSimulateWithRealTransactions tests the full simulation pipeline with real transaction hex.
357+
// This verifies parsing, sender recovery, call object construction, and RPC interaction.
358+
func TestSimulateWithRealTransactions(t *testing.T) {
359+
// Create mock server that accepts any valid simulation request
360+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
361+
var req struct {
362+
Method string `json:"method"`
363+
Params []json.RawMessage `json:"params"`
364+
ID int `json:"id"`
365+
}
366+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
367+
http.Error(w, "bad request", http.StatusBadRequest)
368+
return
369+
}
370+
defer func() { _ = r.Body.Close() }()
371+
372+
w.Header().Set("Content-Type", "application/json")
373+
374+
// Return successful simulation response for eth_simulateV1
375+
if req.Method == "eth_simulateV1" {
376+
response := map[string]interface{}{
377+
"jsonrpc": "2.0",
378+
"id": req.ID,
379+
"result": json.RawMessage(simulateV1ResponseSimple),
380+
}
381+
_ = json.NewEncoder(w).Encode(response)
382+
return
383+
}
384+
385+
// Fallback to debug_traceCall
386+
if req.Method == "debug_traceCall" {
387+
response := map[string]interface{}{
388+
"jsonrpc": "2.0",
389+
"id": req.ID,
390+
"result": json.RawMessage(traceCallResponseSimple),
391+
}
392+
_ = json.NewEncoder(w).Encode(response)
393+
return
394+
}
395+
396+
// Unknown method
397+
response := map[string]interface{}{
398+
"jsonrpc": "2.0",
399+
"id": req.ID,
400+
"error": map[string]interface{}{
401+
"code": -32601,
402+
"message": "Method not found",
403+
},
404+
}
405+
_ = json.NewEncoder(w).Encode(response)
406+
}))
407+
defer srv.Close()
408+
409+
simulator, err := sim.NewInlineSimulator([]string{srv.URL}, nil)
410+
if err != nil {
411+
t.Fatalf("failed to create simulator: %v", err)
412+
}
413+
defer func() { _ = simulator.Close() }()
414+
415+
for _, tc := range realTxVectors {
416+
t.Run(tc.name, func(t *testing.T) {
417+
// Test full simulation pipeline with real tx hex
418+
logs, isSwap, err := simulator.Simulate(context.Background(), tc.rawHex, sim.Latest)
419+
if err != nil {
420+
t.Fatalf("simulation failed: %v", err)
421+
}
422+
423+
// Verify we got a response (mock returns simple transfer with no logs)
424+
if logs == nil {
425+
t.Error("expected non-nil logs slice")
426+
}
427+
428+
t.Logf("tx=%s simulated successfully, logs=%d, isSwap=%v", tc.name, len(logs), isSwap)
429+
})
430+
}
431+
432+
// Test with pending state
433+
t.Run("PendingState", func(t *testing.T) {
434+
logs, isSwap, err := simulator.Simulate(context.Background(), realTxVectors[0].rawHex, sim.Pending)
435+
if err != nil {
436+
t.Fatalf("simulation with pending state failed: %v", err)
437+
}
438+
t.Logf("pending state simulation: logs=%d, isSwap=%v", len(logs), isSwap)
439+
})
440+
}
441+
258442
func TestInlineSimulator(t *testing.T) {
259443
// eth_simulateV1 responses
260444
simV1Responses := map[string]string{
@@ -461,10 +645,7 @@ func TestInlineSimulator(t *testing.T) {
461645

462646
// TestSwapDetection tests the swap detector with realistic trace responses
463647
func TestSwapDetection(t *testing.T) {
464-
// Test nested trace logs collection from aggregator multi-hop
465648
t.Run("NestedTraceLogCollection", func(t *testing.T) {
466-
// Simulate what happens in a multi-hop swap
467-
// The logs are nested inside calls
468649
logs := []sim.TraceLog{
469650
// First hop - SushiSwap (uses same signature as Uniswap V2 Swap)
470651
{
@@ -523,7 +704,6 @@ func TestSwapDetection(t *testing.T) {
523704
}
524705

525706
func TestSwapSignatures(t *testing.T) {
526-
// Test all swap event signatures from rethsim
527707
swapTests := []struct {
528708
name string
529709
topicHash string
@@ -580,7 +760,6 @@ func TestSwapSignatures(t *testing.T) {
580760
})
581761
}
582762

583-
// Test multiple swap events in one transaction (aggregator scenario)
584763
t.Run("DetectMultipleSwaps", func(t *testing.T) {
585764
logs := []sim.TraceLog{
586765
{
@@ -608,7 +787,6 @@ func TestSwapSignatures(t *testing.T) {
608787
}
609788
})
610789

611-
// Test deduplication of same swap type
612790
t.Run("DeduplicateSameSwapType", func(t *testing.T) {
613791
logs := []sim.TraceLog{
614792
{

0 commit comments

Comments
 (0)