Skip to content

Commit 09a176c

Browse files
authored
Merge pull request #56 from ethpandaops/fix/overflow-gas-cost
fix(structlog): sanitize corrupted gasCost values from Erigon underflow bug
2 parents 0c33e62 + fb619bf commit 09a176c

5 files changed

Lines changed: 162 additions & 3 deletions

File tree

pkg/ethereum/execution/geth/rpc.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func (n *RPCNode) traceTransactionErigon(ctx context.Context, hash string, optio
118118
Gas: rsp.Gas,
119119
Failed: rsp.Failed,
120120
ReturnValue: returnValue,
121-
Structlogs: []execution.StructLog{},
121+
Structlogs: make([]execution.StructLog, 0, len(rsp.StructLogs)),
122122
}
123123

124124
// Empty array on transfer
@@ -143,6 +143,9 @@ func (n *RPCNode) traceTransactionErigon(ctx context.Context, hash string, optio
143143
})
144144
}
145145

146+
// Sanitize gasCost values to fix Erigon's unsigned integer underflow bug.
147+
execution.SanitizeStructLogs(result.Structlogs)
148+
146149
return result, nil
147150
}
148151

pkg/ethereum/execution/sanitize.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package execution
2+
3+
// SanitizeGasCost detects and corrects corrupted gasCost values from Erigon's
4+
// debug_traceTransaction RPC.
5+
//
6+
// Bug: Erigon has an unsigned integer underflow bug in gas.go:callGas() where
7+
// `availableGas - base` underflows when availableGas < base, producing huge
8+
// corrupted values (e.g., 18158513697557845033).
9+
//
10+
// Detection: gasCost can never legitimately exceed the available gas at that
11+
// opcode. If gasCost > Gas, the value is corrupted.
12+
//
13+
// Correction: Set gasCost = Gas (all available gas consumed), matching Reth's
14+
// behavior for failed CALL opcodes.
15+
func SanitizeGasCost(log *StructLog) {
16+
if log.GasCost > log.Gas {
17+
log.GasCost = log.Gas
18+
}
19+
}
20+
21+
// SanitizeStructLogs applies gas cost sanitization to all structlogs.
22+
// This corrects corrupted values from Erigon's unsigned integer underflow bug.
23+
func SanitizeStructLogs(logs []StructLog) {
24+
for i := range logs {
25+
SanitizeGasCost(&logs[i])
26+
}
27+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package execution
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestSanitizeGasCost(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
gas uint64
13+
gasCost uint64
14+
expectedGasCost uint64
15+
}{
16+
{
17+
name: "normal gasCost unchanged",
18+
gas: 10000,
19+
gasCost: 3,
20+
expectedGasCost: 3,
21+
},
22+
{
23+
name: "gasCost equals gas unchanged",
24+
gas: 5058,
25+
gasCost: 5058,
26+
expectedGasCost: 5058,
27+
},
28+
{
29+
name: "corrupted gasCost from Erigon underflow bug",
30+
gas: 5058,
31+
gasCost: 18158513697557845033, // 0xfc00000000001429
32+
expectedGasCost: 5058,
33+
},
34+
{
35+
name: "another corrupted value",
36+
gas: 9974,
37+
gasCost: 18158513697557850263, // From test case
38+
expectedGasCost: 9974,
39+
},
40+
{
41+
name: "max uint64 corrupted",
42+
gas: 1000,
43+
gasCost: ^uint64(0), // max uint64
44+
expectedGasCost: 1000,
45+
},
46+
}
47+
48+
for _, tt := range tests {
49+
t.Run(tt.name, func(t *testing.T) {
50+
log := &StructLog{
51+
Gas: tt.gas,
52+
GasCost: tt.gasCost,
53+
}
54+
55+
SanitizeGasCost(log)
56+
57+
assert.Equal(t, tt.expectedGasCost, log.GasCost)
58+
})
59+
}
60+
}
61+
62+
func TestSanitizeStructLogs(t *testing.T) {
63+
logs := []StructLog{
64+
{Op: "PUSH1", Gas: 10000, GasCost: 3},
65+
{Op: "CALL", Gas: 5058, GasCost: 18158513697557845033}, // Corrupted
66+
{Op: "STOP", Gas: 100, GasCost: 0},
67+
}
68+
69+
SanitizeStructLogs(logs)
70+
71+
assert.Equal(t, uint64(3), logs[0].GasCost, "normal gasCost should be unchanged")
72+
assert.Equal(t, uint64(5058), logs[1].GasCost, "corrupted gasCost should be sanitized")
73+
assert.Equal(t, uint64(0), logs[2].GasCost, "zero gasCost should be unchanged")
74+
}

pkg/processor/transaction/structlog_agg/aggregator.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,18 +262,25 @@ func (fa *FrameAggregator) Finalize(trace *execution.TraceTransaction, receiptGa
262262

263263
// Root frame: compute gas refund and intrinsic gas
264264
if frameID == 0 {
265+
// Check trace.Failed to detect transaction failure from REVERT opcodes.
266+
// REVERT executes successfully (no opcode error), but causes transaction failure.
267+
// Individual opcode errors (like "out of gas") are already counted via acc.ErrorCount.
268+
if trace.Failed && summaryRow.ErrorCount == 0 {
269+
summaryRow.ErrorCount = 1
270+
}
271+
265272
// For successful transactions, refunds are applied to the final gas calculation.
266273
// For failed transactions, refunds are accumulated during execution but NOT applied
267274
// (all gas up to failure is consumed), so we set refund to nil.
268-
if acc.ErrorCount == 0 {
275+
if summaryRow.ErrorCount == 0 {
269276
summaryRow.GasRefund = &acc.MaxRefund
270277
}
271278

272279
// Intrinsic gas is ALWAYS charged (before EVM execution begins), regardless of
273280
// whether the transaction succeeds or fails. For failed txs, use refund=0 in
274281
// the formula since refunds aren't applied.
275282
refundForCalc := acc.MaxRefund
276-
if acc.ErrorCount > 0 {
283+
if summaryRow.ErrorCount > 0 {
277284
refundForCalc = 0
278285
}
279286

pkg/processor/transaction/structlog_agg/aggregator_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,54 @@ func TestFrameAggregator_SuccessfulTransaction_HasRefundAndIntrinsic(t *testing.
575575
// Note: might be nil if computed value is 0, so we just check the logic is exercised
576576
}
577577

578+
func TestFrameAggregator_RevertWithoutOpcodeError(t *testing.T) {
579+
// Test that REVERT transactions are correctly detected as failed even when
580+
// the REVERT opcode itself has no error field set.
581+
//
582+
// REVERT is a successful opcode execution that causes transaction failure.
583+
// Unlike "out of gas" errors where the opcode has an error field, REVERT
584+
// executes successfully but reverts state changes. The failure is indicated
585+
// by trace.Failed = true, NOT by individual opcode errors.
586+
aggregator := NewFrameAggregator()
587+
588+
// Simulate a transaction that reverts: PUSH1 -> REVERT
589+
aggregator.ProcessStructlog(&execution.StructLog{
590+
Op: "PUSH1",
591+
Depth: 1,
592+
Gas: 50000,
593+
}, 0, 0, []uint32{0}, 3, 3, nil, nil)
594+
595+
// REVERT opcode with NO error field (realistic behavior)
596+
aggregator.ProcessStructlog(&execution.StructLog{
597+
Op: "REVERT",
598+
Depth: 1,
599+
Gas: 49997,
600+
// Note: NO Error field set - REVERT executes successfully
601+
}, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1})
602+
603+
// trace.Failed is true because the transaction reverted
604+
trace := &execution.TraceTransaction{
605+
Gas: 50000,
606+
Failed: true, // This is how REVERT is indicated
607+
}
608+
609+
frames := aggregator.Finalize(trace, 30000)
610+
611+
assert.Equal(t, 1, countSummaryRows(frames))
612+
613+
summaryRow := getSummaryRow(frames, 0)
614+
require.NotNil(t, summaryRow)
615+
616+
// Error count MUST be 1 even though no opcode had an error field
617+
// This is the key assertion: trace.Failed should set error_count = 1
618+
assert.Equal(t, uint64(1), summaryRow.ErrorCount,
619+
"ErrorCount should be 1 for REVERT transactions even without opcode errors")
620+
621+
// GasRefund should be nil for failed transactions
622+
assert.Nil(t, summaryRow.GasRefund,
623+
"GasRefund should be nil for reverted transactions")
624+
}
625+
578626
func TestMapOpcodeToCallType(t *testing.T) {
579627
tests := []struct {
580628
opcode string

0 commit comments

Comments
 (0)