From 526f42815295085d9f35c7d012fc8f5a2cca056e Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Mon, 16 Feb 2026 13:53:56 +1000 Subject: [PATCH 1/8] feat(structlog): add cold access detection and memory expansion utilities feat(structlog): expose IsPrecompile helper for external use feat(execution): extend StructLog with MemorySize, CallTransfersValue, ExtCodeCopySize test(structlog): add comprehensive tests for cold access and memory expansion --- pkg/ethereum/execution/structlog.go | 15 + .../structlog/call_tracker_test.go | 16 +- .../transaction/structlog/cold_access.go | 267 ++++++++++++++ .../transaction/structlog/cold_access_test.go | 329 ++++++++++++++++++ pkg/processor/transaction/structlog/memory.go | 184 ++++++++++ .../transaction/structlog/memory_test.go | 194 +++++++++++ .../structlog/transaction_processing.go | 8 +- 7 files changed, 1001 insertions(+), 12 deletions(-) create mode 100644 pkg/processor/transaction/structlog/cold_access.go create mode 100644 pkg/processor/transaction/structlog/cold_access_test.go create mode 100644 pkg/processor/transaction/structlog/memory.go create mode 100644 pkg/processor/transaction/structlog/memory_test.go diff --git a/pkg/ethereum/execution/structlog.go b/pkg/ethereum/execution/structlog.go index 9d00e69..a3347ab 100644 --- a/pkg/ethereum/execution/structlog.go +++ b/pkg/ethereum/execution/structlog.go @@ -64,4 +64,19 @@ type StructLog struct { // In embedded mode: pre-extracted by tracer from stack[len-2]. // In RPC mode: nil, extracted post-hoc from Stack by extractCallAddress(). CallToAddress *string `json:"callToAddress,omitempty"` + + // MemorySize is the EVM memory size in bytes at the time this opcode executes. + // Used to compute memory expansion gas between consecutive opcodes. + // In embedded mode: captured by tracer from scope.MemoryData(). + // In RPC mode: 0 (not available). + MemorySize uint32 `json:"memSize,omitempty"` + + // CallTransfersValue indicates whether a CALL/CALLCODE transfers non-zero ETH value. + // True only for CALL/CALLCODE with value > 0 on the stack. + // Used to normalize CALL gas for cold access detection. + CallTransfersValue bool `json:"callTransfersValue,omitempty"` + + // ExtCodeCopySize is the size parameter for EXTCODECOPY opcodes. + // Used to compute the copy cost component for cold access detection. + ExtCodeCopySize uint32 `json:"extCodeCopySize,omitempty"` } diff --git a/pkg/processor/transaction/structlog/call_tracker_test.go b/pkg/processor/transaction/structlog/call_tracker_test.go index 5c30408..6bc0aa3 100644 --- a/pkg/processor/transaction/structlog/call_tracker_test.go +++ b/pkg/processor/transaction/structlog/call_tracker_test.go @@ -313,8 +313,8 @@ func TestIsPrecompile(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result := isPrecompile(tc.addr) - assert.Equal(t, tc.expected, result, "isPrecompile(%q) = %v, want %v", tc.addr, result, tc.expected) + result := IsPrecompile(tc.addr) + assert.Equal(t, tc.expected, result, "IsPrecompile(%q) = %v, want %v", tc.addr, result, tc.expected) }) } } @@ -347,7 +347,7 @@ func TestIsPrecompile_HardcodedList(t *testing.T) { // Verify all expected precompiles are detected for _, addr := range expectedPrecompiles { - assert.True(t, isPrecompile(addr), + assert.True(t, IsPrecompile(addr), "precompile %s should be detected", addr) } @@ -411,7 +411,7 @@ func TestEOADetectionLogic(t *testing.T) { // Depth increase = entered contract code (not EOA) // Depth decrease = call returned/failed (not EOA) // Depth same = called EOA or precompile (immediate return) - if nextDepth == currentDepth && !isPrecompile(callToAddr) { + if nextDepth == currentDepth && !IsPrecompile(callToAddr) { return true } @@ -589,11 +589,11 @@ func TestEOADetectionBugScenario_DepthDecrease(t *testing.T) { callToAddr := "0xde9c774cde34f85ee69c22e9a1077a0c9091f09b" // Old buggy logic: nextDepth <= currentDepth → 2 <= 3 → TRUE (wrong!) - buggyLogic := nextDepth <= currentDepth && !isPrecompile(callToAddr) + buggyLogic := nextDepth <= currentDepth && !IsPrecompile(callToAddr) assert.True(t, buggyLogic, "Old buggy logic would have created synthetic frame") // Fixed logic: nextDepth == currentDepth → 2 == 3 → FALSE (correct!) - fixedLogic := nextDepth == currentDepth && !isPrecompile(callToAddr) + fixedLogic := nextDepth == currentDepth && !IsPrecompile(callToAddr) assert.False(t, fixedLogic, "Fixed logic should NOT create synthetic frame") } @@ -609,11 +609,11 @@ func TestEOADetectionBugScenario_OutOfGas(t *testing.T) { hasNextOpcode := false // Old buggy logic: "Last opcode is a CALL - if not precompile, must be EOA" - buggyLogic := !hasNextOpcode && !isPrecompile(callToAddr) + buggyLogic := !hasNextOpcode && !IsPrecompile(callToAddr) assert.True(t, buggyLogic, "Old buggy logic would have created synthetic frame") // Fixed logic: Don't assume last CALL is EOA - we can't determine without next opcode - fixedLogic := hasNextOpcode && !isPrecompile(callToAddr) // Always false when !hasNextOpcode + fixedLogic := hasNextOpcode && !IsPrecompile(callToAddr) // Always false when !hasNextOpcode assert.False(t, fixedLogic, "Fixed logic should NOT create synthetic frame for last opcode") } diff --git a/pkg/processor/transaction/structlog/cold_access.go b/pkg/processor/transaction/structlog/cold_access.go new file mode 100644 index 0000000..7a8f86f --- /dev/null +++ b/pkg/processor/transaction/structlog/cold_access.go @@ -0,0 +1,267 @@ +package structlog + +import ( + "math" + "strconv" + "strings" + + "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" +) + +// EVM gas constants for cold/warm access detection (EIP-2929). +const ( + warmAccessCost = 100 + coldSloadCost = 2100 + coldAccountCost = 2600 + + // CALL value transfer adds 9000 gas (6700 stipend + 2300 callValueTransferGas). + callValueTransferGas = 9000 + + // Minimum word copy cost for EXTCODECOPY (3 gas per word). + wordCopyCost = 3 +) + +// ClassifyColdAccess returns per-opcode cold access counts (0, 1, or 2). +// It uses gas values and memory expansion costs to determine whether each +// access-list-affected opcode performed a cold or warm access. +// +// The gasSelf parameter should contain the gas excluding child frame gas +// (as computed by ComputeGasSelf). The memExpGas parameter contains the +// memory expansion gas for each opcode (nil if memory data is unavailable, +// in which case memory expansion is assumed to be 0). +// +// Returns a slice of cold access counts corresponding to each structlog index. +// Returns nil if structlogs is empty. +func ClassifyColdAccess( + structlogs []execution.StructLog, + gasSelf []uint64, + memExpGas []uint64, +) []uint64 { + if len(structlogs) == 0 { + return nil + } + + coldCounts := make([]uint64, len(structlogs)) + + for i := range structlogs { + coldCounts[i] = classifyOpcode(&structlogs[i], gasSelf[i], getMemExp(memExpGas, i)) + } + + return coldCounts +} + +// classifyOpcode determines the cold access count for a single opcode. +func classifyOpcode(sl *execution.StructLog, gasSelf, memExp uint64) uint64 { + switch sl.Op { + case "SLOAD": + return classifySload(sl.GasCost) + + case "SSTORE": + return classifySstore(sl.GasCost) + + case "BALANCE", "EXTCODESIZE", "EXTCODEHASH": + return classifyAccountAccess(sl.GasCost) + + case OpcodeCALL, OpcodeSTATICCALL, OpcodeDELEGATECALL, OpcodeCALLCODE: + return classifyCall(sl, gasSelf, memExp) + + case "EXTCODECOPY": + return classifyExtCodeCopy(sl, gasSelf, memExp) + + case "SELFDESTRUCT": + return classifySelfdestruct(sl.GasCost) + + default: + return 0 + } +} + +// classifySload: cold if GasCost >= 2100 (cold SLOAD cost). +func classifySload(gasCost uint64) uint64 { + if gasCost >= coldSloadCost { + return 1 + } + + return 0 +} + +// classifySstore: cold if GasCost is one of the cold SSTORE variants. +// Cold SSTORE costs: 22100 (SET+cold), 5000 (RESET+cold), 2200 (noop+cold). +// Warm SSTORE costs: 20000 (SET), 2900 (RESET), 100 (noop/warm). +func classifySstore(gasCost uint64) uint64 { + switch gasCost { + case 22100, 5000, 2200: + return 1 + default: + return 0 + } +} + +// classifyAccountAccess: cold if GasCost >= 2600 (cold account access cost). +func classifyAccountAccess(gasCost uint64) uint64 { + if gasCost >= coldAccountCost { + return 1 + } + + return 0 +} + +// classifyCall determines cold access count for CALL-family opcodes. +// Normalizes gas by subtracting memory expansion and value transfer costs, +// then uses range-based detection: +// - remaining <= 200: 0 cold (warm, possibly with stipend adjustments) +// - remaining 2600-2700: 1 cold (single cold account access) +// - remaining >= 5200: 2 cold (cold account + cold value transfer to new account) +func classifyCall(sl *execution.StructLog, gasSelf, memExp uint64) uint64 { + remaining := gasSelf + + // Subtract memory expansion cost. + if remaining > memExp { + remaining -= memExp + } else { + remaining = 0 + } + + // Subtract value transfer cost for CALL/CALLCODE with non-zero value. + // Use tracer field if set; otherwise fall back to stack in RPC mode. + if (sl.Op == OpcodeCALL || sl.Op == OpcodeCALLCODE) && callHasValue(sl) { + if remaining > callValueTransferGas { + remaining -= callValueTransferGas + } else { + remaining = 0 + } + } + + // Precompile targets are always warm (EIP-2929 pre-warms them). + if sl.CallToAddress != nil && IsPrecompile(*sl.CallToAddress) { + return 0 + } + + // Range-based classification. + if remaining <= 200 { + return 0 + } + + if remaining >= 5200 { + return 2 + } + + if remaining >= 2600 { + return 1 + } + + return 0 +} + +// callHasValue returns true if a CALL/CALLCODE transfers non-zero ETH value. +// In embedded mode, uses the pre-computed CallTransfersValue field. +// In RPC mode, falls back to checking stack[len-3] (the value operand). +func callHasValue(sl *execution.StructLog) bool { + if sl.CallTransfersValue { + return true + } + + // RPC fallback: value is the 3rd element from the top of the stack. + if sl.Stack != nil && len(*sl.Stack) > 2 { + return !isHexZero((*sl.Stack)[len(*sl.Stack)-3]) + } + + return false +} + +// extCodeCopySize returns the EXTCODECOPY size operand. +// In embedded mode, uses the pre-computed ExtCodeCopySize field. +// In RPC mode, falls back to parsing stack[len-4] (the size operand). +func extCodeCopySize(sl *execution.StructLog) uint32 { + if sl.ExtCodeCopySize > 0 { + return sl.ExtCodeCopySize + } + + // RPC fallback: size is the 4th element from the top of the stack. + if sl.Stack != nil && len(*sl.Stack) > 3 { + return parseHexUint32((*sl.Stack)[len(*sl.Stack)-4]) + } + + return 0 +} + +// classifyExtCodeCopy determines cold access count for EXTCODECOPY. +// Normalizes gas by subtracting memory expansion and copy costs. +func classifyExtCodeCopy(sl *execution.StructLog, gasSelf, memExp uint64) uint64 { + remaining := gasSelf + + // Subtract memory expansion cost. + if remaining > memExp { + remaining -= memExp + } else { + remaining = 0 + } + + // Subtract copy cost: 3 gas per 32-byte word (rounded up). + size := extCodeCopySize(sl) + copyWords := (uint64(size) + 31) / 32 + copyCost := copyWords * wordCopyCost + + if remaining > copyCost { + remaining -= copyCost + } else { + remaining = 0 + } + + if remaining >= coldAccountCost { + return 1 + } + + return 0 +} + +// classifySelfdestruct: cold if GasCost indicates cold access. +// Cold SELFDESTRUCT costs: 7600 (cold target), 32600 (cold + new account). +func classifySelfdestruct(gasCost uint64) uint64 { + switch gasCost { + case 7600, 32600: + return 1 + default: + return 0 + } +} + +// getMemExp safely retrieves memory expansion gas, returning 0 if the slice is nil. +func getMemExp(memExpGas []uint64, i int) uint64 { + if memExpGas == nil || i >= len(memExpGas) { + return 0 + } + + return memExpGas[i] +} + +// isHexZero returns true if the hex string represents zero. +func isHexZero(s string) bool { + hex := strings.TrimPrefix(s, "0x") + if hex == "" { + return true + } + + for _, c := range hex { + if c != '0' { + return false + } + } + + return true +} + +// parseHexUint32 parses a hex string to uint32, clamping to math.MaxUint32. +func parseHexUint32(s string) uint32 { + hex := strings.TrimPrefix(s, "0x") + if hex == "" { + return 0 + } + + v, err := strconv.ParseUint(hex, 16, 64) + if err != nil || v > math.MaxUint32 { + return math.MaxUint32 + } + + return uint32(v) +} diff --git a/pkg/processor/transaction/structlog/cold_access_test.go b/pkg/processor/transaction/structlog/cold_access_test.go new file mode 100644 index 0000000..7e6bd0c --- /dev/null +++ b/pkg/processor/transaction/structlog/cold_access_test.go @@ -0,0 +1,329 @@ +package structlog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" +) + +func TestClassifyColdAccess_Empty(t *testing.T) { + result := ClassifyColdAccess(nil, nil, nil) + assert.Nil(t, result) +} + +func TestClassifyColdAccess_SLOAD(t *testing.T) { + tests := []struct { + name string + gasCost uint64 + expected uint64 + }{ + {"warm SLOAD (100)", 100, 0}, + {"cold SLOAD (2100)", 2100, 1}, + {"cold SLOAD (2200 - with extra)", 2200, 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "SLOAD", GasCost: tc.gasCost, Depth: 1}, + } + gasSelf := []uint64{tc.gasCost} + + result := ClassifyColdAccess(structlogs, gasSelf, nil) + assert.Equal(t, tc.expected, result[0]) + }) + } +} + +func TestClassifyColdAccess_SSTORE(t *testing.T) { + tests := []struct { + name string + gasCost uint64 + expected uint64 + }{ + {"warm noop (100)", 100, 0}, + {"cold noop (2200)", 2200, 1}, + {"warm RESET (2900)", 2900, 0}, + {"cold RESET (5000)", 5000, 1}, + {"warm SET (20000)", 20000, 0}, + {"cold SET (22100)", 22100, 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "SSTORE", GasCost: tc.gasCost, Depth: 1}, + } + gasSelf := []uint64{tc.gasCost} + + result := ClassifyColdAccess(structlogs, gasSelf, nil) + assert.Equal(t, tc.expected, result[0]) + }) + } +} + +func TestClassifyColdAccess_AccountAccess(t *testing.T) { + opcodes := []string{"BALANCE", "EXTCODESIZE", "EXTCODEHASH"} + + for _, opcode := range opcodes { + t.Run(opcode+"_warm", func(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: opcode, GasCost: 100, Depth: 1}, + } + + result := ClassifyColdAccess(structlogs, []uint64{100}, nil) + assert.Equal(t, uint64(0), result[0]) + }) + + t.Run(opcode+"_cold", func(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: opcode, GasCost: 2600, Depth: 1}, + } + + result := ClassifyColdAccess(structlogs, []uint64{2600}, nil) + assert.Equal(t, uint64(1), result[0]) + }) + } +} + +func TestClassifyColdAccess_CALL_EIP7702(t *testing.T) { + // Test CALL cold detection with various remaining gas values. + tests := []struct { + name string + gasSelf uint64 + expected uint64 + }{ + {"warm (100)", 100, 0}, + {"warm (200)", 200, 0}, + {"cold single (2600)", 2600, 1}, + {"cold single (2700)", 2700, 1}, + {"cold double (5200)", 5200, 2}, + {"cold double (5300)", 5300, 2}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + addr := "0x1234567890123456789012345678901234567890" + structlogs := []execution.StructLog{ + {Op: "STATICCALL", GasCost: tc.gasSelf, Depth: 1, CallToAddress: &addr}, + } + + result := ClassifyColdAccess(structlogs, []uint64{tc.gasSelf}, nil) + assert.Equal(t, tc.expected, result[0]) + }) + } +} + +func TestClassifyColdAccess_CALL_WithValueTransfer(t *testing.T) { + // CALL with value transfer: remaining = gasSelf - memExp - 9000. + addr := "0x1234567890123456789012345678901234567890" + + tests := []struct { + name string + gasSelf uint64 + expected uint64 + }{ + // 9100 - 9000 = 100 (warm) + {"warm with value", 9100, 0}, + // 11600 - 9000 = 2600 (cold) + {"cold with value", 11600, 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + structlogs := []execution.StructLog{ + { + Op: "CALL", GasCost: tc.gasSelf, Depth: 1, + CallToAddress: &addr, CallTransfersValue: true, + }, + } + + result := ClassifyColdAccess(structlogs, []uint64{tc.gasSelf}, nil) + assert.Equal(t, tc.expected, result[0]) + }) + } +} + +func TestClassifyColdAccess_CALL_Precompile(t *testing.T) { + // Precompile targets are always warm. + precompileAddr := "0x0000000000000000000000000000000000000001" + structlogs := []execution.StructLog{ + {Op: "CALL", GasCost: 5000, Depth: 1, CallToAddress: &precompileAddr}, + } + + result := ClassifyColdAccess(structlogs, []uint64{5000}, nil) + assert.Equal(t, uint64(0), result[0]) +} + +func TestClassifyColdAccess_EXTCODECOPY(t *testing.T) { + tests := []struct { + name string + gasSelf uint64 + memExp uint64 + extCodeCopySize uint32 + expected uint64 + }{ + // remaining = 100 - 0 - 0 = 100 (warm) + {"warm no copy", 100, 0, 0, 0}, + // remaining = 2700 - 0 - 3*1 = 2697 (>= 2600 = cold) + {"cold with small copy", 2700, 0, 32, 1}, + // remaining = 2700 - 100 - 3*1 = 2597 (< 2600 = warm) + {"warm with memExp", 2700, 100, 32, 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "EXTCODECOPY", GasCost: tc.gasSelf, Depth: 1, ExtCodeCopySize: tc.extCodeCopySize}, + } + + var memExpGas []uint64 + if tc.memExp > 0 { + memExpGas = []uint64{tc.memExp} + } + + result := ClassifyColdAccess(structlogs, []uint64{tc.gasSelf}, memExpGas) + assert.Equal(t, tc.expected, result[0]) + }) + } +} + +func TestClassifyColdAccess_SELFDESTRUCT(t *testing.T) { + tests := []struct { + name string + gasCost uint64 + expected uint64 + }{ + {"warm (5000)", 5000, 0}, + {"cold target (7600)", 7600, 1}, + {"cold + new account (32600)", 32600, 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "SELFDESTRUCT", GasCost: tc.gasCost, Depth: 1}, + } + + result := ClassifyColdAccess(structlogs, []uint64{tc.gasCost}, nil) + assert.Equal(t, tc.expected, result[0]) + }) + } +} + +func TestClassifyColdAccess_NonAccessOpcodes(t *testing.T) { + // Non-access opcodes should always return 0. + opcodes := []string{"ADD", "MUL", "PUSH1", "MSTORE", "JUMP", "RETURN", "STOP"} + + for _, opcode := range opcodes { + t.Run(opcode, func(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: opcode, GasCost: 3, Depth: 1}, + } + + result := ClassifyColdAccess(structlogs, []uint64{3}, nil) + assert.Equal(t, uint64(0), result[0]) + }) + } +} + +func TestClassifyColdAccess_CALL_RPCValueFallback(t *testing.T) { + // RPC mode: CallTransfersValue=false but stack[len-3] is non-zero. + // Should detect value transfer via stack fallback. + addr := "0x1234567890123456789012345678901234567890" + + tests := []struct { + name string + gasSelf uint64 + value string + expected uint64 + }{ + // 11600 - 9000 = 2600 (cold) — value detected from stack. + {"cold with stack value", 11600, "de0b6b3a7640000", 1}, + // 11600 - 0 = 11600 (>= 5200 = 2 cold) — zero value, no subtraction. + {"no value zero stack", 11600, "0", 2}, + // 9100 - 9000 = 100 (warm) — value detected from stack. + {"warm with stack value", 9100, "1", 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Build stack: CALL stack is [gas, addr, value, ...] from top. + // Stack slice is bottom-to-top, so value is at len-3. + stack := []string{"0", "0", "0", "0", tc.value, addr, "ffffffff"} + structlogs := []execution.StructLog{ + { + Op: "CALL", GasCost: tc.gasSelf, Depth: 1, + CallToAddress: &addr, Stack: &stack, + }, + } + + result := ClassifyColdAccess(structlogs, []uint64{tc.gasSelf}, nil) + assert.Equal(t, tc.expected, result[0]) + }) + } +} + +func TestClassifyColdAccess_EXTCODECOPY_RPCSizeFallback(t *testing.T) { + // RPC mode: ExtCodeCopySize=0 but stack[len-4] has the size. + tests := []struct { + name string + gasSelf uint64 + sizeHex string + expected uint64 + }{ + // remaining = 2700 - 0 - 3*1word = 2697 (>= 2600 = cold). + // Size 0x20=32 bytes = 1 word, copyCost = 3. + {"cold with stack size", 2700, "20", 1}, + // remaining = 2700 - 0 - 0 = 2700 (>= 2600 = cold). + // Size 0 = 0 words, copyCost = 0. + {"cold zero size", 2700, "0", 1}, + // remaining = 2700 - 0 - 3*4words = 2688 (>= 2600 = cold). + // Size 0x80=128 bytes = 4 words, copyCost = 12. + {"cold with larger size", 2700, "80", 1}, + // remaining = 2700 - 0 - 3*32words = 2604 (>= 2600 = cold). + // Size 0x400=1024 bytes = 32 words, copyCost = 96. + {"cold with 1024 bytes", 2700, "400", 1}, + // remaining = 2700 - 0 - 3*33words = 2601 (>= 2600 = cold). + // Size 0x420=1056 bytes = 33 words, copyCost = 99. + {"borderline cold 1056 bytes", 2700, "420", 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // EXTCODECOPY stack: [addr, destOffset, offset, size] from top. + // Stack slice is bottom-to-top, so size is at len-4. + stack := []string{tc.sizeHex, "0", "0", "0xaddr"} + structlogs := []execution.StructLog{ + {Op: "EXTCODECOPY", GasCost: tc.gasSelf, Depth: 1, Stack: &stack}, + } + + result := ClassifyColdAccess(structlogs, []uint64{tc.gasSelf}, nil) + assert.Equal(t, tc.expected, result[0]) + }) + } +} + +func TestIsPrecompile_ColdAccess(t *testing.T) { + tests := []struct { + addr string + expected bool + }{ + {"0x0000000000000000000000000000000000000001", true}, + {"0x0000000000000000000000000000000000000009", true}, + {"0x000000000000000000000000000000000000000a", true}, + {"0x0000000000000000000000000000000000000011", true}, + {"0x0000000000000000000000000000000000000100", true}, + {"0x0000000000000000000000000000000000000012", false}, + {"0x1234567890123456789012345678901234567890", false}, + {"0x0000000000000000000000000000000000000000", false}, + } + + for _, tc := range tests { + t.Run(tc.addr, func(t *testing.T) { + assert.Equal(t, tc.expected, IsPrecompile(tc.addr)) + }) + } +} diff --git a/pkg/processor/transaction/structlog/memory.go b/pkg/processor/transaction/structlog/memory.go new file mode 100644 index 0000000..3e6dbff --- /dev/null +++ b/pkg/processor/transaction/structlog/memory.go @@ -0,0 +1,184 @@ +package structlog + +import ( + "math" + "strconv" + "strings" + + "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" +) + +// ComputeMemoryWords computes the EVM memory size in 32-byte words before and +// after each opcode executes. This is derived from the MemorySize field which +// captures the memory length at opcode entry. +// +// For consecutive same-depth opcodes: +// - wordsBefore[i] = ceil(structlogs[i].MemorySize / 32) +// - wordsAfter[i] = ceil(structlogs[i+1].MemorySize / 32) +// +// At depth transitions and for the last opcode in a frame, wordsAfter = wordsBefore +// (we assume no expansion since we can't observe the post-execution state), +// except for RETURN/REVERT which compute wordsAfter from their stack operands +// (offset + size) since these opcodes can expand memory. +// +// Returns (nil, nil) if MemorySize data is unavailable (RPC mode where MemorySize=0). +func ComputeMemoryWords(structlogs []execution.StructLog) (wordsBefore, wordsAfter []uint32) { + if len(structlogs) == 0 { + return nil, nil + } + + // Detect if MemorySize data is available. In RPC mode all values are 0. + // Heuristic: if the first structlog has MemorySize=0, check if ANY structlog + // has non-zero MemorySize. If none do, data is unavailable. + hasData := false + + for i := range structlogs { + if structlogs[i].MemorySize > 0 { + hasData = true + + break + } + } + + if !hasData { + return nil, nil + } + + wordsBefore = make([]uint32, len(structlogs)) + wordsAfter = make([]uint32, len(structlogs)) + + // pendingIdx[depth] = index of the pending opcode at that depth. + // Same pattern as ComputeGasUsed - deferred resolution across same-depth opcodes. + pendingIdx := make([]int, 0, 16) + + for i := range structlogs { + depthU64 := structlogs[i].Depth + if depthU64 > math.MaxInt { + depthU64 = math.MaxInt + } + + depth := int(depthU64) //nolint:gosec // overflow checked above + + // Ensure slice has enough space for this depth. + for len(pendingIdx) <= depth { + pendingIdx = append(pendingIdx, -1) + } + + // Clear pending indices from deeper levels (returned from calls). + for d := len(pendingIdx) - 1; d > depth; d-- { + if prevIdx := pendingIdx[d]; prevIdx >= 0 { + wordsAfter[prevIdx] = resolveLastInFrame(&structlogs[prevIdx]) + } + + pendingIdx[d] = -1 + } + + // Resolve pending opcode at current depth: its wordsAfter is our wordsBefore. + wb := memoryWords(structlogs[i].MemorySize) + wordsBefore[i] = wb + + if prevIdx := pendingIdx[depth]; prevIdx >= 0 && prevIdx < len(structlogs) { + wordsAfter[prevIdx] = wb + } + + pendingIdx[depth] = i + } + + // Finalize any remaining pending opcodes (last in their depth). + for d := range pendingIdx { + if prevIdx := pendingIdx[d]; prevIdx >= 0 { + wordsAfter[prevIdx] = resolveLastInFrame(&structlogs[prevIdx]) + } + } + + return wordsBefore, wordsAfter +} + +// resolveLastInFrame determines wordsAfter for the last opcode in a call frame. +// For RETURN/REVERT, computes ceil((offset + size) / 32) from the stack operands +// since these opcodes can expand memory. Returns the larger of wordsBefore and +// the stack-derived value to avoid undercounting. +// For all other opcodes, falls back to wordsBefore (no observable expansion). +func resolveLastInFrame(sl *execution.StructLog) uint32 { + wb := memoryWords(sl.MemorySize) + + if sl.Op != "RETURN" && sl.Op != "REVERT" { + return wb + } + + // RETURN/REVERT stack layout: [offset, size, ...] (top-of-stack first). + // Try embedded ReturnSize field first, then fall back to stack. + endBytes := returnEndBytes(sl) + if endBytes == 0 { + return wb + } + + wa := memoryWords(endBytes) + + // Memory only grows; use the larger value. + if wa > wb { + return wa + } + + return wb +} + +// returnEndBytes computes offset+size for RETURN/REVERT from the stack. +// Returns 0 if stack is unavailable or both operands are zero. +func returnEndBytes(sl *execution.StructLog) uint32 { + if sl.Stack == nil || len(*sl.Stack) < 2 { + return 0 + } + + stack := *sl.Stack + offset := parseHexUint64(stack[len(stack)-1]) + size := parseHexUint64(stack[len(stack)-2]) + + end := offset + size + // Overflow or exceeding uint32 range: clamp. + if end < offset || end > math.MaxUint32 { + return math.MaxUint32 + } + + return uint32(end) +} + +// parseHexUint64 parses a hex string (with optional 0x prefix) to uint64. +// Returns 0 on parse error. +func parseHexUint64(s string) uint64 { + hex := strings.TrimPrefix(s, "0x") + if hex == "" { + return 0 + } + + v, err := strconv.ParseUint(hex, 16, 64) + if err != nil { + return 0 + } + + return v +} + +// MemoryExpansionGas computes the gas cost of expanding EVM memory from +// wordsBefore to wordsAfter words. Returns 0 if no expansion occurred. +// +// The EVM memory cost formula is: cost = 3*words + words²/512. +// Memory expansion gas = cost(wordsAfter) - cost(wordsBefore). +func MemoryExpansionGas(wordsBefore, wordsAfter uint32) uint64 { + if wordsAfter <= wordsBefore { + return 0 + } + + return memoryCost(uint64(wordsAfter)) - memoryCost(uint64(wordsBefore)) +} + +// memoryCost computes the EVM memory gas cost for a given number of words. +// Formula: 3 * words + words² / 512. +func memoryCost(words uint64) uint64 { + return 3*words + (words*words)/512 +} + +// memoryWords returns ceil(byteSize / 32) — the number of 32-byte words. +func memoryWords(byteSize uint32) uint32 { + return (byteSize + 31) / 32 +} diff --git a/pkg/processor/transaction/structlog/memory_test.go b/pkg/processor/transaction/structlog/memory_test.go new file mode 100644 index 0000000..f0a9820 --- /dev/null +++ b/pkg/processor/transaction/structlog/memory_test.go @@ -0,0 +1,194 @@ +package structlog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" +) + +func TestComputeMemoryWords_Empty(t *testing.T) { + wb, wa := ComputeMemoryWords(nil) + assert.Nil(t, wb) + assert.Nil(t, wa) +} + +func TestComputeMemoryWords_NoMemoryData(t *testing.T) { + // RPC mode: all MemorySize values are 0. + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1, MemorySize: 0}, + {Op: "ADD", Depth: 1, MemorySize: 0}, + } + + wb, wa := ComputeMemoryWords(structlogs) + assert.Nil(t, wb) + assert.Nil(t, wa) +} + +func TestComputeMemoryWords_SameDepthSequence(t *testing.T) { + // Three opcodes at same depth with growing memory. + // Memory: 0 -> 32 -> 96 bytes (0 -> 1 -> 3 words). + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1, MemorySize: 0}, + {Op: "MSTORE", Depth: 1, MemorySize: 32}, + {Op: "MSTORE", Depth: 1, MemorySize: 96}, + } + + wb, wa := ComputeMemoryWords(structlogs) + require.NotNil(t, wb) + require.NotNil(t, wa) + + // Op 0: wordsBefore=0, wordsAfter=ceil(32/32)=1 + assert.Equal(t, uint32(0), wb[0]) + assert.Equal(t, uint32(1), wa[0]) + + // Op 1: wordsBefore=1, wordsAfter=ceil(96/32)=3 + assert.Equal(t, uint32(1), wb[1]) + assert.Equal(t, uint32(3), wa[1]) + + // Op 2: last opcode, wordsAfter=wordsBefore=3 + assert.Equal(t, uint32(3), wb[2]) + assert.Equal(t, uint32(3), wa[2]) +} + +func TestComputeMemoryWords_DepthTransition(t *testing.T) { + // depth 1 -> depth 2 -> back to depth 1 + structlogs := []execution.StructLog{ + {Op: "CALL", Depth: 1, MemorySize: 64}, // 2 words + {Op: "PUSH1", Depth: 2, MemorySize: 0}, // 0 words (child frame starts fresh) + {Op: "MSTORE", Depth: 2, MemorySize: 32}, // 1 word + {Op: "RETURN", Depth: 2, MemorySize: 128}, // 4 words + {Op: "POP", Depth: 1, MemorySize: 64}, // back to parent: 2 words + } + + wb, wa := ComputeMemoryWords(structlogs) + require.NotNil(t, wb) + + // Op 0 (CALL, depth 1): wordsBefore=2, wordsAfter resolved when we return to depth 1 (Op 4) + assert.Equal(t, uint32(2), wb[0]) + assert.Equal(t, uint32(2), wa[0]) // POP at depth 1 has memSize=64 -> 2 words + + // Op 1 (depth 2): wordsBefore=0, wordsAfter=1 + assert.Equal(t, uint32(0), wb[1]) + assert.Equal(t, uint32(1), wa[1]) + + // Op 2 (depth 2): wordsBefore=1, wordsAfter=4 + assert.Equal(t, uint32(1), wb[2]) + assert.Equal(t, uint32(4), wa[2]) + + // Op 3 (RETURN, depth 2): last at depth 2, wordsAfter=wordsBefore=4 + assert.Equal(t, uint32(4), wb[3]) + assert.Equal(t, uint32(4), wa[3]) + + // Op 4 (POP, depth 1): last opcode, wordsAfter=wordsBefore=2 + assert.Equal(t, uint32(2), wb[4]) + assert.Equal(t, uint32(2), wa[4]) +} + +func TestComputeMemoryWords_ReturnExpandsMemory(t *testing.T) { + // RETURN with offset=0, size=256 should expand memory beyond the 128 bytes + // already allocated. Stack: [offset, size] = top-of-stack first. + stack := []string{"0", "100"} // offset=0, size=0x100=256 + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1, MemorySize: 64}, // 2 words + {Op: "MSTORE", Depth: 1, MemorySize: 64}, // 2 words + {Op: "RETURN", Depth: 1, MemorySize: 128, Stack: &stack}, // 4 words before, RETURN needs ceil(256/32)=8 words + } + + wb, wa := ComputeMemoryWords(structlogs) + require.NotNil(t, wb) + + // Op 2 (RETURN, last in frame): wordsBefore=4, wordsAfter=ceil(256/32)=8. + assert.Equal(t, uint32(4), wb[2]) + assert.Equal(t, uint32(8), wa[2]) + + // Verify expansion gas is non-zero. + gas := MemoryExpansionGas(wb[2], wa[2]) + // cost(8) - cost(4) = (24 + 64/512) - (12 + 16/512) = 24 - 12 = 12 + assert.Equal(t, uint64(12), gas) +} + +func TestComputeMemoryWords_RevertExpandsMemory(t *testing.T) { + // REVERT with offset=32, size=64 should expand to ceil((32+64)/32)=3 words. + stack := []string{"20", "40"} // offset=0x20=32, size=0x40=64 + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1, MemorySize: 32}, // 1 word + {Op: "REVERT", Depth: 1, MemorySize: 32, Stack: &stack}, + } + + wb, wa := ComputeMemoryWords(structlogs) + require.NotNil(t, wb) + + // Op 1 (REVERT): wordsBefore=1, wordsAfter=ceil(96/32)=3. + assert.Equal(t, uint32(1), wb[1]) + assert.Equal(t, uint32(3), wa[1]) +} + +func TestComputeMemoryWords_ReturnNoExpansion(t *testing.T) { + // RETURN with offset=0, size=32 — memory already has 64 bytes, no expansion. + stack := []string{"0", "20"} // offset=0, size=0x20=32 + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1, MemorySize: 64}, + {Op: "RETURN", Depth: 1, MemorySize: 64, Stack: &stack}, + } + + wb, wa := ComputeMemoryWords(structlogs) + require.NotNil(t, wb) + + // Op 1 (RETURN): wordsBefore=2, wordsAfter=max(2, ceil(32/32)=1) = 2. + assert.Equal(t, uint32(2), wb[1]) + assert.Equal(t, uint32(2), wa[1]) +} + +func TestComputeMemoryWords_ReturnNoStack(t *testing.T) { + // RETURN without stack data (embedded mode) — falls back to wordsBefore. + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1, MemorySize: 64}, + {Op: "RETURN", Depth: 1, MemorySize: 128}, + } + + wb, wa := ComputeMemoryWords(structlogs) + require.NotNil(t, wb) + + // Op 1 (RETURN, no stack): wordsAfter=wordsBefore=4. + assert.Equal(t, uint32(4), wb[1]) + assert.Equal(t, uint32(4), wa[1]) +} + +func TestMemoryExpansionGas_NoExpansion(t *testing.T) { + assert.Equal(t, uint64(0), MemoryExpansionGas(5, 5)) + assert.Equal(t, uint64(0), MemoryExpansionGas(5, 3)) +} + +func TestMemoryExpansionGas_SmallExpansion(t *testing.T) { + // 0 -> 1 word: cost(1) - cost(0) = 3*1 + 1/512 - 0 = 3 + assert.Equal(t, uint64(3), MemoryExpansionGas(0, 1)) +} + +func TestMemoryExpansionGas_LargerExpansion(t *testing.T) { + // 1 -> 3 words: cost(3) - cost(1) = (9 + 9/512) - (3 + 1/512) = 9 - 3 = 6 + // cost(3) = 3*3 + 9/512 = 9 + 0 = 9 + // cost(1) = 3*1 + 1/512 = 3 + 0 = 3 + assert.Equal(t, uint64(6), MemoryExpansionGas(1, 3)) +} + +func TestMemoryExpansionGas_QuadraticKicksIn(t *testing.T) { + // Large expansion where quadratic term matters. + // 0 -> 100 words: cost(100) = 3*100 + 100*100/512 = 300 + 19 = 319 + assert.Equal(t, uint64(319), MemoryExpansionGas(0, 100)) +} + +func TestMemoryWords_Rounding(t *testing.T) { + assert.Equal(t, uint32(0), memoryWords(0)) + assert.Equal(t, uint32(1), memoryWords(1)) + assert.Equal(t, uint32(1), memoryWords(31)) + assert.Equal(t, uint32(1), memoryWords(32)) + assert.Equal(t, uint32(2), memoryWords(33)) + assert.Equal(t, uint32(2), memoryWords(64)) + assert.Equal(t, uint32(3), memoryWords(65)) +} diff --git a/pkg/processor/transaction/structlog/transaction_processing.go b/pkg/processor/transaction/structlog/transaction_processing.go index ff10c9a..07d2512 100644 --- a/pkg/processor/transaction/structlog/transaction_processing.go +++ b/pkg/processor/transaction/structlog/transaction_processing.go @@ -100,9 +100,9 @@ var precompileAddresses = map[string]bool{ "0x0000000000000000000000000000000000000100": true, // p256Verify (EIP-7212, Osaka) } -// isPrecompile returns true if the address is a known EVM precompile. +// IsPrecompile returns true if the address is a known EVM precompile. // Precompile calls don't appear in trace_transaction results (unlike EOA calls which do). -func isPrecompile(addr string) bool { +func IsPrecompile(addr string) bool { // Normalize to lowercase with 0x prefix and full 40 hex chars hex := strings.TrimPrefix(strings.ToLower(addr), "0x") @@ -237,7 +237,7 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc if i+1 < totalCount { // Next opcode exists - check if depth stayed the same nextDepth := trace.Structlogs[i+1].Depth - if nextDepth == sl.Depth && !isPrecompile(*callToAddr) { + if nextDepth == sl.Depth && !IsPrecompile(*callToAddr) { isEOACall = true } } @@ -566,7 +566,7 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block execution.Block // Depth decrease = call returned/failed (not EOA) // Depth same = called EOA or precompile (immediate return) nextDepth := trace.Structlogs[i+1].Depth - if nextDepth == structLog.Depth && !isPrecompile(*callToAddr) { + if nextDepth == structLog.Depth && !IsPrecompile(*callToAddr) { isEOACall = true } } From 1fafb326a97802f31e6636cdd013bb3b8b4d4c67 Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Mon, 16 Feb 2026 13:57:28 +1000 Subject: [PATCH 2/8] feat(structlog_agg): add resource gas decomposition metrics Introduce memory and cold-access counters to enable gas cost breakdown into memory expansion and state-access categories. - Extend CallFrameRow and OpcodeStats with memory-words sums (before/after) and squared sums plus cold-access counts. - Update aggregator to accumulate the new fields per opcode and roll them up to frame summaries. - Add memWordsBefore, memWordsAfter, coldAccessCount params to ProcessStructlog and wire them in transaction processing. - Extend ClickHouse columns and batch insert logic to persist the new metrics. - Remove local precompile list and delegate to structlog pkg. --- .../transaction/structlog_agg/aggregator.go | 73 +++++++++++---- .../structlog_agg/aggregator_test.go | 88 +++++++++---------- .../transaction/structlog_agg/columns.go | 65 +++++++++----- .../transaction/structlog_agg/processor.go | 5 ++ .../structlog_agg/transaction_processing.go | 74 +++++++++------- 5 files changed, 192 insertions(+), 113 deletions(-) diff --git a/pkg/processor/transaction/structlog_agg/aggregator.go b/pkg/processor/transaction/structlog_agg/aggregator.go index fd3fb11..d5820a7 100644 --- a/pkg/processor/transaction/structlog_agg/aggregator.go +++ b/pkg/processor/transaction/structlog_agg/aggregator.go @@ -25,6 +25,13 @@ type CallFrameRow struct { MaxDepth uint32 // Per-opcode: MAX(depth); summary: same as Depth GasRefund *uint64 // Root frame only (max refund from trace) IntrinsicGas *uint64 // Root frame only (computed) + + // Resource gas building blocks. + MemWordsSumBefore uint64 + MemWordsSumAfter uint64 + MemWordsSqSumBefore uint64 + MemWordsSqSumAfter uint64 + ColdAccessCount uint64 } // OpcodeStats tracks gas and count for a specific opcode within a frame. @@ -35,6 +42,13 @@ type OpcodeStats struct { ErrorCount uint64 MinDepth uint32 MaxDepth uint32 + + // Resource gas building blocks for decomposing gas into categories. + MemWordsSumBefore uint64 // SUM(memory_words_before) + MemWordsSumAfter uint64 // SUM(memory_words_after) + MemWordsSqSumBefore uint64 // SUM(memory_words_before²) + MemWordsSqSumAfter uint64 // SUM(memory_words_after²) + ColdCount uint64 // Number of cold accesses } // FrameAccumulator tracks data for a single frame during processing. @@ -80,6 +94,9 @@ func NewFrameAggregator() *FrameAggregator { // - gasSelf: Pre-computed gas used excluding child frame gas // - callToAddr: Target address for CALL/CREATE opcodes (nil otherwise) // - prevStructlog: Previous structlog (for detecting frame entry via CALL/CREATE) +// - memWordsBefore: Memory words before this opcode (0 if unavailable) +// - memWordsAfter: Memory words after this opcode (0 if unavailable) +// - coldAccessCount: Number of cold accesses for this opcode (0, 1, or 2) func (fa *FrameAggregator) ProcessStructlog( sl *execution.StructLog, index int, @@ -89,6 +106,9 @@ func (fa *FrameAggregator) ProcessStructlog( gasSelf uint64, callToAddr *string, prevStructlog *execution.StructLog, + memWordsBefore uint32, + memWordsAfter uint32, + coldAccessCount uint64, ) { acc, exists := fa.frames[frameID] if !exists { @@ -142,6 +162,15 @@ func (fa *FrameAggregator) ProcessStructlog( stats.Gas += gasSelf // SUM(gas_self) - excludes child frame gas stats.GasCumulative += gasUsed // SUM(gas_used) - includes child frame gas + // Accumulate resource gas building blocks. + wb := uint64(memWordsBefore) + wa := uint64(memWordsAfter) + stats.MemWordsSumBefore += wb + stats.MemWordsSumAfter += wa + stats.MemWordsSqSumBefore += wb * wb + stats.MemWordsSqSumAfter += wa * wa + stats.ColdCount += coldAccessCount + // Track min/max depth if depth < stats.MinDepth { stats.MinDepth = depth @@ -290,26 +319,40 @@ func (fa *FrameAggregator) Finalize(trace *execution.TraceTransaction, receiptGa } } + // Compute summary-level resource gas totals (SUM across all opcodes). + for _, stats := range acc.OpcodeStats { + summaryRow.MemWordsSumBefore += stats.MemWordsSumBefore + summaryRow.MemWordsSumAfter += stats.MemWordsSumAfter + summaryRow.MemWordsSqSumBefore += stats.MemWordsSqSumBefore + summaryRow.MemWordsSqSumAfter += stats.MemWordsSqSumAfter + summaryRow.ColdAccessCount += stats.ColdCount + } + rows = append(rows, summaryRow) // Emit per-opcode rows for opcode, stats := range acc.OpcodeStats { opcodeRow := CallFrameRow{ - CallFrameID: frameID, - ParentCallFrameID: parentFrameID, - CallFramePath: acc.CallFramePath, - Depth: depth, - TargetAddress: acc.TargetAddress, - CallType: acc.CallType, - Operation: opcode, - OpcodeCount: stats.Count, - ErrorCount: stats.ErrorCount, - Gas: stats.Gas, - GasCumulative: stats.GasCumulative, // SUM(gas_used) for per-opcode rows - MinDepth: stats.MinDepth, - MaxDepth: stats.MaxDepth, - GasRefund: nil, - IntrinsicGas: nil, + CallFrameID: frameID, + ParentCallFrameID: parentFrameID, + CallFramePath: acc.CallFramePath, + Depth: depth, + TargetAddress: acc.TargetAddress, + CallType: acc.CallType, + Operation: opcode, + OpcodeCount: stats.Count, + ErrorCount: stats.ErrorCount, + Gas: stats.Gas, + GasCumulative: stats.GasCumulative, // SUM(gas_used) for per-opcode rows + MinDepth: stats.MinDepth, + MaxDepth: stats.MaxDepth, + GasRefund: nil, + IntrinsicGas: nil, + MemWordsSumBefore: stats.MemWordsSumBefore, + MemWordsSumAfter: stats.MemWordsSumAfter, + MemWordsSqSumBefore: stats.MemWordsSqSumBefore, + MemWordsSqSumAfter: stats.MemWordsSqSumAfter, + ColdAccessCount: stats.ColdCount, } rows = append(rows, opcodeRow) } diff --git a/pkg/processor/transaction/structlog_agg/aggregator_test.go b/pkg/processor/transaction/structlog_agg/aggregator_test.go index bb8033c..ea42a62 100644 --- a/pkg/processor/transaction/structlog_agg/aggregator_test.go +++ b/pkg/processor/transaction/structlog_agg/aggregator_test.go @@ -82,7 +82,7 @@ func TestFrameAggregator_SingleFrame(t *testing.T) { } // For simple opcodes, gasSelf == gasUsed - aggregator.ProcessStructlog(execSl, i, 0, framePath, sl.gasUsed, sl.gasUsed, nil, prevSl) + aggregator.ProcessStructlog(execSl, i, 0, framePath, sl.gasUsed, sl.gasUsed, nil, prevSl, 0, 0, 0) } trace := &execution.TraceTransaction{ @@ -127,14 +127,14 @@ func TestFrameAggregator_NestedCalls(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) // CALL opcode: gasUsed includes child gas, gasSelf is just the CALL overhead aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 9997, - }, 1, 0, []uint32{0}, 5000, 100, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, 5000, 100, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // Frame 1 (child) - depth 2 callAddr := testAddress @@ -143,20 +143,20 @@ func TestFrameAggregator_NestedCalls(t *testing.T) { Op: "PUSH1", Depth: 2, Gas: 5000, - }, 2, 1, []uint32{0, 1}, 3, 3, &callAddr, &execution.StructLog{Op: "CALL", Depth: 1}) + }, 2, 1, []uint32{0, 1}, 3, 3, &callAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "RETURN", Depth: 2, Gas: 4997, - }, 3, 1, []uint32{0, 1}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 2}) + }, 3, 1, []uint32{0, 1}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 2}, 0, 0, 0) // Back to root frame aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 4997, - }, 4, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "RETURN", Depth: 2}) + }, 4, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "RETURN", Depth: 2}, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 10000, @@ -200,14 +200,14 @@ func TestFrameAggregator_ErrorCounting(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 1000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "REVERT", Depth: 1, Gas: 997, Error: &errMsg, - }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 1000, @@ -382,13 +382,13 @@ func TestFrameAggregator_EOAFrame(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 9997, - }, 1, 0, []uint32{0}, 100, 100, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, 100, 100, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // Synthetic EOA frame (operation = "", depth = 2) eoaAddr := "0xEOAEOAEOAEOAEOAEOAEOAEOAEOAEOAEOAEOAEOAE" @@ -397,14 +397,14 @@ func TestFrameAggregator_EOAFrame(t *testing.T) { Op: "", // Empty = synthetic EOA row Depth: 2, Gas: 0, - }, 1, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}) + }, 1, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) // Back to root frame aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 9897, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 10000, @@ -441,7 +441,7 @@ func TestFrameAggregator_SetRootTargetAddress(t *testing.T) { Op: "STOP", Depth: 1, Gas: 1000, - }, 0, 0, []uint32{0}, 0, 0, nil, nil) + }, 0, 0, []uint32{0}, 0, 0, nil, nil, 0, 0, 0) // Set root target address (simulating tx.To()) rootAddr := testAddress @@ -477,7 +477,7 @@ func TestFrameAggregator_FailedTransaction_NoRefundButHasIntrinsic(t *testing.T) Op: "PUSH1", Depth: 1, Gas: 80000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) // SSTORE that generates a refund aggregator.ProcessStructlog(&execution.StructLog{ @@ -485,7 +485,7 @@ func TestFrameAggregator_FailedTransaction_NoRefundButHasIntrinsic(t *testing.T) Depth: 1, Gas: 79997, Refund: &refundValue, // Refund accumulated - }, 1, 0, []uint32{0}, 20000, 20000, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, 20000, 20000, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // Transaction fails with REVERT aggregator.ProcessStructlog(&execution.StructLog{ @@ -494,7 +494,7 @@ func TestFrameAggregator_FailedTransaction_NoRefundButHasIntrinsic(t *testing.T) Gas: 59997, Error: &errMsg, Refund: &refundValue, // Refund still present but won't be applied - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SSTORE", Depth: 1}) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SSTORE", Depth: 1}, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 80000, @@ -533,7 +533,7 @@ func TestFrameAggregator_SuccessfulTransaction_HasRefundAndIntrinsic(t *testing. Op: "PUSH1", Depth: 1, Gas: 80000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) // SSTORE that generates a refund aggregator.ProcessStructlog(&execution.StructLog{ @@ -541,7 +541,7 @@ func TestFrameAggregator_SuccessfulTransaction_HasRefundAndIntrinsic(t *testing. Depth: 1, Gas: 79997, Refund: &refundValue, - }, 1, 0, []uint32{0}, 20000, 20000, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, 20000, 20000, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // Successful STOP aggregator.ProcessStructlog(&execution.StructLog{ @@ -549,7 +549,7 @@ func TestFrameAggregator_SuccessfulTransaction_HasRefundAndIntrinsic(t *testing. Depth: 1, Gas: 59997, Refund: &refundValue, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SSTORE", Depth: 1}) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SSTORE", Depth: 1}, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 80000, @@ -592,7 +592,7 @@ func TestFrameAggregator_RevertWithoutOpcodeError(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 50000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) // REVERT opcode with NO error field (realistic behavior) aggregator.ProcessStructlog(&execution.StructLog{ @@ -600,7 +600,7 @@ func TestFrameAggregator_RevertWithoutOpcodeError(t *testing.T) { Depth: 1, Gas: 49997, // Note: NO Error field set - REVERT executes successfully - }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // trace.Failed is true because the transaction reverted trace := &execution.TraceTransaction{ @@ -661,7 +661,7 @@ func TestFrameAggregator_PrecompileFrame(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) // CALL to precompile: gasSelf=3100 (100 overhead + 3000 precompile execution). // With precompile gas extraction: @@ -671,20 +671,20 @@ func TestFrameAggregator_PrecompileFrame(t *testing.T) { Op: "CALL", Depth: 1, Gas: 9997, - }, 1, 0, []uint32{0}, 3100, 100, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, 3100, 100, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // Synthetic precompile frame (gas = precompileGas = 3000) aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 1, 1, []uint32{0, 1}, 3000, 3000, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}) + }, 1, 1, []uint32{0, 1}, 3000, 3000, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) // Back to root frame aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 6897, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 10000, @@ -735,26 +735,26 @@ func TestFrameAggregator_PrecompileGasSplitInvariant(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 20000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) // CALL with effectiveGasSelf = overhead aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 19997, - }, 1, 0, []uint32{0}, originalGasSelf, overhead, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, originalGasSelf, overhead, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // Synthetic precompile frame aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 1, 1, []uint32{0, 1}, precompileGas, precompileGas, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}) + }, 1, 1, []uint32{0, 1}, precompileGas, precompileGas, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 14897, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) trace := &execution.TraceTransaction{Gas: 20000, Failed: false} frames := aggregator.Finalize(trace, 10000) @@ -781,26 +781,26 @@ func TestFrameAggregator_EOAFrameUnchanged(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) // CALL to EOA: gasSelf=100, no precompile gas extraction aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 9997, - }, 1, 0, []uint32{0}, 100, 100, &eoaAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, 100, 100, &eoaAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // Synthetic EOA frame (gas = 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 1, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}) + }, 1, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 9897, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) trace := &execution.TraceTransaction{Gas: 10000, Failed: false} frames := aggregator.Finalize(trace, 5000) @@ -830,46 +830,46 @@ func TestFrameAggregator_MultiplePrecompileCalls(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 50000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) // First precompile call: ecrecover (gas = 3100 = 100 + 3000) aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 49997, - }, 1, 0, []uint32{0}, 3100, 100, &ecrecoverAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, 3100, 100, &ecrecoverAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // Synthetic frame for ecrecover aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 1, 1, []uint32{0, 1}, 3000, 3000, &ecrecoverAddr, &execution.StructLog{Op: "CALL", Depth: 1}) + }, 1, 1, []uint32{0, 1}, 3000, 3000, &ecrecoverAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) // Some opcodes between the two precompile calls aggregator.ProcessStructlog(&execution.StructLog{ Op: "PUSH1", Depth: 1, Gas: 46897, - }, 2, 0, []uint32{0}, 3, 3, nil, &execution.StructLog{Op: "", Depth: 2}) + }, 2, 0, []uint32{0}, 3, 3, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) // Second precompile call: sha256 (gas = 1100 = 100 + 1000) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STATICCALL", Depth: 1, Gas: 46894, - }, 3, 0, []uint32{0}, 1100, 100, &sha256Addr, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 3, 0, []uint32{0}, 1100, 100, &sha256Addr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // Synthetic frame for sha256 aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 3, 2, []uint32{0, 2}, 1000, 1000, &sha256Addr, &execution.StructLog{Op: "STATICCALL", Depth: 1}) + }, 3, 2, []uint32{0, 2}, 1000, 1000, &sha256Addr, &execution.StructLog{Op: "STATICCALL", Depth: 1}, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 45794, - }, 4, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}) + }, 4, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) trace := &execution.TraceTransaction{Gas: 50000, Failed: false} frames := aggregator.Finalize(trace, 30000) @@ -903,7 +903,7 @@ func TestFrameAggregator_PrecompileGasSelfLessThanOverhead(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) // CALL to precompile with gasSelf=50 (less than overhead=100) // This shouldn't split — effectiveGasSelf stays 50 @@ -911,19 +911,19 @@ func TestFrameAggregator_PrecompileGasSelfLessThanOverhead(t *testing.T) { Op: "CALL", Depth: 1, Gas: 9997, - }, 1, 0, []uint32{0}, 50, 50, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}) + }, 1, 0, []uint32{0}, 50, 50, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) // Synthetic frame with gas=0 (no precompile gas extracted) aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 1, 1, []uint32{0, 1}, 0, 0, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}) + }, 1, 1, []uint32{0, 1}, 0, 0, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 9947, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) trace := &execution.TraceTransaction{Gas: 10000, Failed: false} frames := aggregator.Finalize(trace, 5000) diff --git a/pkg/processor/transaction/structlog_agg/columns.go b/pkg/processor/transaction/structlog_agg/columns.go index d301175..4165d19 100644 --- a/pkg/processor/transaction/structlog_agg/columns.go +++ b/pkg/processor/transaction/structlog_agg/columns.go @@ -21,26 +21,31 @@ func (t ClickHouseTime) Time() time.Time { // Columns holds all columns for structlog_agg batch insert using ch-go columnar protocol. type Columns struct { - UpdatedDateTime proto.ColDateTime - BlockNumber proto.ColUInt64 - TransactionHash proto.ColStr - TransactionIndex proto.ColUInt32 - CallFrameID proto.ColUInt32 - ParentCallFrameID *proto.ColNullable[uint32] - CallFramePath *proto.ColArr[uint32] // Path from root to this frame - Depth proto.ColUInt32 - TargetAddress *proto.ColNullable[string] - CallType proto.ColStr - Operation proto.ColStr // Empty string for summary row, opcode name for per-opcode rows - OpcodeCount proto.ColUInt64 - ErrorCount proto.ColUInt64 - Gas proto.ColUInt64 // SUM(gas_self) - excludes child frame gas - GasCumulative proto.ColUInt64 // For summary: frame gas_cumulative; for per-opcode: SUM(gas_used) - MinDepth proto.ColUInt32 // Per-opcode: MIN(depth); summary: same as Depth - MaxDepth proto.ColUInt32 // Per-opcode: MAX(depth); summary: same as Depth - GasRefund *proto.ColNullable[uint64] - IntrinsicGas *proto.ColNullable[uint64] - MetaNetworkName proto.ColStr + UpdatedDateTime proto.ColDateTime + BlockNumber proto.ColUInt64 + TransactionHash proto.ColStr + TransactionIndex proto.ColUInt32 + CallFrameID proto.ColUInt32 + ParentCallFrameID *proto.ColNullable[uint32] + CallFramePath *proto.ColArr[uint32] // Path from root to this frame + Depth proto.ColUInt32 + TargetAddress *proto.ColNullable[string] + CallType proto.ColStr + Operation proto.ColStr // Empty string for summary row, opcode name for per-opcode rows + OpcodeCount proto.ColUInt64 + ErrorCount proto.ColUInt64 + Gas proto.ColUInt64 // SUM(gas_self) - excludes child frame gas + GasCumulative proto.ColUInt64 // For summary: frame gas_cumulative; for per-opcode: SUM(gas_used) + MinDepth proto.ColUInt32 // Per-opcode: MIN(depth); summary: same as Depth + MaxDepth proto.ColUInt32 // Per-opcode: MAX(depth); summary: same as Depth + GasRefund *proto.ColNullable[uint64] + IntrinsicGas *proto.ColNullable[uint64] + MemWordsSumBefore proto.ColUInt64 + MemWordsSumAfter proto.ColUInt64 + MemWordsSqSumBefore proto.ColUInt64 + MemWordsSqSumAfter proto.ColUInt64 + ColdAccessCount proto.ColUInt64 + MetaNetworkName proto.ColStr } // NewColumns creates a new Columns instance with all columns initialized. @@ -75,6 +80,11 @@ func (c *Columns) Append( maxDepth uint32, gasRefund *uint64, intrinsicGas *uint64, + memWordsSumBefore uint64, + memWordsSumAfter uint64, + memWordsSqSumBefore uint64, + memWordsSqSumAfter uint64, + coldAccessCount uint64, network string, ) { c.UpdatedDateTime.Append(updatedDateTime) @@ -96,6 +106,11 @@ func (c *Columns) Append( c.MaxDepth.Append(maxDepth) c.GasRefund.Append(nullableUint64(gasRefund)) c.IntrinsicGas.Append(nullableUint64(intrinsicGas)) + c.MemWordsSumBefore.Append(memWordsSumBefore) + c.MemWordsSumAfter.Append(memWordsSumAfter) + c.MemWordsSqSumBefore.Append(memWordsSqSumBefore) + c.MemWordsSqSumAfter.Append(memWordsSqSumAfter) + c.ColdAccessCount.Append(coldAccessCount) c.MetaNetworkName.Append(network) } @@ -120,6 +135,11 @@ func (c *Columns) Reset() { c.MaxDepth.Reset() c.GasRefund.Reset() c.IntrinsicGas.Reset() + c.MemWordsSumBefore.Reset() + c.MemWordsSumAfter.Reset() + c.MemWordsSqSumBefore.Reset() + c.MemWordsSqSumAfter.Reset() + c.ColdAccessCount.Reset() c.MetaNetworkName.Reset() } @@ -145,6 +165,11 @@ func (c *Columns) Input() proto.Input { {Name: "max_depth", Data: &c.MaxDepth}, {Name: "gas_refund", Data: c.GasRefund}, {Name: "intrinsic_gas", Data: c.IntrinsicGas}, + {Name: "memory_words_sum_before", Data: &c.MemWordsSumBefore}, + {Name: "memory_words_sum_after", Data: &c.MemWordsSumAfter}, + {Name: "memory_words_sq_sum_before", Data: &c.MemWordsSqSumBefore}, + {Name: "memory_words_sq_sum_after", Data: &c.MemWordsSqSumAfter}, + {Name: "cold_access_count", Data: &c.ColdAccessCount}, {Name: "meta_network_name", Data: &c.MetaNetworkName}, } } diff --git a/pkg/processor/transaction/structlog_agg/processor.go b/pkg/processor/transaction/structlog_agg/processor.go index 7f7c03b..2147210 100644 --- a/pkg/processor/transaction/structlog_agg/processor.go +++ b/pkg/processor/transaction/structlog_agg/processor.go @@ -315,6 +315,11 @@ func (p *Processor) flushRows(ctx context.Context, rows []insertRow) error { row.Frame.MaxDepth, row.Frame.GasRefund, row.Frame.IntrinsicGas, + row.Frame.MemWordsSumBefore, + row.Frame.MemWordsSumAfter, + row.Frame.MemWordsSqSumBefore, + row.Frame.MemWordsSqSumAfter, + row.Frame.ColdAccessCount, row.Network, ) } diff --git a/pkg/processor/transaction/structlog_agg/transaction_processing.go b/pkg/processor/transaction/structlog_agg/transaction_processing.go index 98a5aa0..81f3929 100644 --- a/pkg/processor/transaction/structlog_agg/transaction_processing.go +++ b/pkg/processor/transaction/structlog_agg/transaction_processing.go @@ -112,6 +112,20 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc createAddresses = computeCreateAddresses(trace.Structlogs) } + // Compute memory words and expansion gas for resource gas decomposition. + wordsBefore, wordsAfter := structlog.ComputeMemoryWords(trace.Structlogs) + + var memExpGas []uint64 + if wordsBefore != nil { + memExpGas = make([]uint64, len(trace.Structlogs)) + for i := range trace.Structlogs { + memExpGas[i] = structlog.MemoryExpansionGas(wordsBefore[i], wordsAfter[i]) + } + } + + // Classify cold vs warm access for each opcode. + coldCounts := structlog.ClassifyColdAccess(trace.Structlogs, gasSelf, memExpGas) + // Initialize frame aggregator aggregator := NewFrameAggregator() @@ -131,7 +145,7 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc callToAddr := p.extractCallAddressWithCreate(sl, i, createAddresses) // Before processing parent CALL: detect precompile and compute gas split. - // Precompile gas = gasSelf minus CALL overhead (warm access cost = 100). + // Precompile gas = gasSelf minus CALL overhead, adjusted for memory expansion. // Precompiles are always warm (EIP-2929 pre-warms them). effectiveGasSelf := gasSelf[i] @@ -140,7 +154,13 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc if isCallOpcode(sl.Op) && callToAddr != nil && i+1 < len(trace.Structlogs) { nextDepth := trace.Structlogs[i+1].Depth if nextDepth == sl.Depth && isPrecompile(*callToAddr) { - overhead := uint64(100) + memExp := uint64(0) + if memExpGas != nil { + memExp = memExpGas[i] + } + + overhead := uint64(100) + memExp + if gasSelf[i] > overhead { precompileGas = gasSelf[i] - overhead effectiveGasSelf = overhead @@ -148,8 +168,22 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc } } + // Get per-opcode resource gas values (0 if data unavailable). + var wb, wa uint32 + + var cold uint64 + + if wordsBefore != nil { + wb = wordsBefore[i] + wa = wordsAfter[i] + } + + if coldCounts != nil { + cold = coldCounts[i] + } + // Process this structlog into the aggregator with (possibly reduced) gasSelf - aggregator.ProcessStructlog(sl, i, frameID, framePath, gasUsed[i], effectiveGasSelf, callToAddr, prevStructlog) + aggregator.ProcessStructlog(sl, i, frameID, framePath, gasUsed[i], effectiveGasSelf, callToAddr, prevStructlog, wb, wa, cold) // Emit synthetic frame for ALL immediate-return CALLs (EOA + precompile). // For EOA calls: precompileGas = 0, so frame has gas=0 (unchanged behavior). @@ -162,7 +196,7 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: sl.Depth + 1, - }, i, synthFrameID, synthFramePath, precompileGas, precompileGas, callToAddr, sl) + }, i, synthFrameID, synthFramePath, precompileGas, precompileGas, callToAddr, sl, 0, 0, 0) } } } @@ -295,37 +329,9 @@ func isCallOpcode(op string) bool { } } -// precompileAddresses contains all known EVM precompile addresses. -var precompileAddresses = map[string]bool{ - "0x0000000000000000000000000000000000000001": true, // ecrecover - "0x0000000000000000000000000000000000000002": true, // sha256 - "0x0000000000000000000000000000000000000003": true, // ripemd160 - "0x0000000000000000000000000000000000000004": true, // identity (dataCopy) - "0x0000000000000000000000000000000000000005": true, // modexp (bigModExp) - "0x0000000000000000000000000000000000000006": true, // bn256Add (ecAdd) - "0x0000000000000000000000000000000000000007": true, // bn256ScalarMul (ecMul) - "0x0000000000000000000000000000000000000008": true, // bn256Pairing (ecPairing) - "0x0000000000000000000000000000000000000009": true, // blake2f - "0x000000000000000000000000000000000000000a": true, // kzgPointEvaluation (EIP-4844, Cancun) - "0x000000000000000000000000000000000000000b": true, // bls12381G1Add (EIP-2537, Osaka) - "0x000000000000000000000000000000000000000c": true, // bls12381G1MultiExp (EIP-2537, Osaka) - "0x000000000000000000000000000000000000000d": true, // bls12381G2Add (EIP-2537, Osaka) - "0x000000000000000000000000000000000000000e": true, // bls12381G2MultiExp (EIP-2537, Osaka) - "0x000000000000000000000000000000000000000f": true, // bls12381Pairing (EIP-2537, Osaka) - "0x0000000000000000000000000000000000000010": true, // bls12381MapG1 (EIP-2537, Osaka) - "0x0000000000000000000000000000000000000011": true, // bls12381MapG2 (EIP-2537, Osaka) - "0x0000000000000000000000000000000000000100": true, // p256Verify (EIP-7212, Osaka) -} - -// isPrecompile returns true if the address is a known EVM precompile. +// isPrecompile delegates to the structlog package's exported IsPrecompile. func isPrecompile(addr string) bool { - hex := strings.TrimPrefix(strings.ToLower(addr), "0x") - - for len(hex) < 40 { - hex = "0" + hex - } - - return precompileAddresses["0x"+hex] + return structlog.IsPrecompile(addr) } // hasPrecomputedGasUsed detects whether GasUsed values are pre-computed by the tracer. From d9521469efa60e390c76a0f011ae8cebcd15f3ce Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Mon, 16 Feb 2026 14:02:50 +1000 Subject: [PATCH 3/8] refactor(cold_access_test): extract magic test address into named constant test(memory_test): add comprehensive unit tests for resolveLastInFrame style(memory): remove outdated comment about RETURN/REVERT stack layout --- .../transaction/structlog/cold_access_test.go | 10 ++- pkg/processor/transaction/structlog/memory.go | 1 - .../transaction/structlog/memory_test.go | 73 +++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/pkg/processor/transaction/structlog/cold_access_test.go b/pkg/processor/transaction/structlog/cold_access_test.go index 7e6bd0c..11143d3 100644 --- a/pkg/processor/transaction/structlog/cold_access_test.go +++ b/pkg/processor/transaction/structlog/cold_access_test.go @@ -8,6 +8,8 @@ import ( "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" ) +const testNonPrecompileAddr = "0x1234567890123456789012345678901234567890" + func TestClassifyColdAccess_Empty(t *testing.T) { result := ClassifyColdAccess(nil, nil, nil) assert.Nil(t, result) @@ -105,7 +107,7 @@ func TestClassifyColdAccess_CALL_EIP7702(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - addr := "0x1234567890123456789012345678901234567890" + addr := testNonPrecompileAddr structlogs := []execution.StructLog{ {Op: "STATICCALL", GasCost: tc.gasSelf, Depth: 1, CallToAddress: &addr}, } @@ -118,7 +120,7 @@ func TestClassifyColdAccess_CALL_EIP7702(t *testing.T) { func TestClassifyColdAccess_CALL_WithValueTransfer(t *testing.T) { // CALL with value transfer: remaining = gasSelf - memExp - 9000. - addr := "0x1234567890123456789012345678901234567890" + addr := testNonPrecompileAddr tests := []struct { name string @@ -232,7 +234,7 @@ func TestClassifyColdAccess_NonAccessOpcodes(t *testing.T) { func TestClassifyColdAccess_CALL_RPCValueFallback(t *testing.T) { // RPC mode: CallTransfersValue=false but stack[len-3] is non-zero. // Should detect value transfer via stack fallback. - addr := "0x1234567890123456789012345678901234567890" + addr := testNonPrecompileAddr tests := []struct { name string @@ -317,7 +319,7 @@ func TestIsPrecompile_ColdAccess(t *testing.T) { {"0x0000000000000000000000000000000000000011", true}, {"0x0000000000000000000000000000000000000100", true}, {"0x0000000000000000000000000000000000000012", false}, - {"0x1234567890123456789012345678901234567890", false}, + {testNonPrecompileAddr, false}, {"0x0000000000000000000000000000000000000000", false}, } diff --git a/pkg/processor/transaction/structlog/memory.go b/pkg/processor/transaction/structlog/memory.go index 3e6dbff..9299786 100644 --- a/pkg/processor/transaction/structlog/memory.go +++ b/pkg/processor/transaction/structlog/memory.go @@ -107,7 +107,6 @@ func resolveLastInFrame(sl *execution.StructLog) uint32 { } // RETURN/REVERT stack layout: [offset, size, ...] (top-of-stack first). - // Try embedded ReturnSize field first, then fall back to stack. endBytes := returnEndBytes(sl) if endBytes == 0 { return wb diff --git a/pkg/processor/transaction/structlog/memory_test.go b/pkg/processor/transaction/structlog/memory_test.go index f0a9820..0a010d9 100644 --- a/pkg/processor/transaction/structlog/memory_test.go +++ b/pkg/processor/transaction/structlog/memory_test.go @@ -160,6 +160,79 @@ func TestComputeMemoryWords_ReturnNoStack(t *testing.T) { assert.Equal(t, uint32(4), wa[1]) } +func TestResolveLastInFrame(t *testing.T) { + tests := []struct { + name string + op string + memSize uint32 + stack *[]string + expected uint32 + }{ + { + name: "RETURN expands memory", + op: "RETURN", + memSize: 64, // 2 words before + stack: &[]string{"0", "80"}, // offset=0, size=0x80=128 → ceil(128/32)=4 + expected: 4, + }, + { + name: "REVERT expands memory", + op: "REVERT", + memSize: 32, // 1 word before + stack: &[]string{"20", "40"}, // offset=0x20=32, size=0x40=64 → ceil(96/32)=3 + expected: 3, + }, + { + name: "RETURN no expansion (within existing)", + op: "RETURN", + memSize: 128, // 4 words before + stack: &[]string{"0", "20"}, // offset=0, size=0x20=32 → ceil(32/32)=1, max(4,1)=4 + expected: 4, + }, + { + name: "RETURN zero size", + op: "RETURN", + memSize: 64, // 2 words before + stack: &[]string{"0", "0"}, // offset=0, size=0 → 0, fallback to 2 + expected: 2, + }, + { + name: "RETURN no stack (embedded mode)", + op: "RETURN", + memSize: 96, // 3 words before + stack: nil, + expected: 3, + }, + { + name: "STOP (non-RETURN opcode)", + op: "STOP", + memSize: 64, // 2 words before + stack: &[]string{"0", "80"}, // stack ignored for non-RETURN + expected: 2, + }, + { + name: "RETURN offset+size causes expansion", + op: "RETURN", + memSize: 0, // 0 words before + stack: &[]string{"100", "100"}, // offset=0x100=256, size=0x100=256 → ceil(512/32)=16 + expected: 16, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sl := &execution.StructLog{ + Op: tc.op, + MemorySize: tc.memSize, + Stack: tc.stack, + } + + result := resolveLastInFrame(sl) + assert.Equal(t, tc.expected, result) + }) + } +} + func TestMemoryExpansionGas_NoExpansion(t *testing.T) { assert.Equal(t, uint64(0), MemoryExpansionGas(5, 5)) assert.Equal(t, uint64(0), MemoryExpansionGas(5, 3)) From 52ed8ea5349668b6b669700c328bcb3f7c3d64ad Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Mon, 16 Feb 2026 14:21:19 +1000 Subject: [PATCH 4/8] test: add comprehensive unit tests for cold access and memory helpers test: add boundary precision tests for cold access classification test: add unit tests for memory parsing and word computation test: add aggregator tests for resource gas accumulation and nested frames test: add column tests for new resource gas fields --- .../transaction/structlog/cold_access_test.go | 217 ++++++++++++++++ .../transaction/structlog/memory_test.go | 95 +++++++ .../structlog_agg/aggregator_test.go | 232 ++++++++++++++++++ 3 files changed, 544 insertions(+) diff --git a/pkg/processor/transaction/structlog/cold_access_test.go b/pkg/processor/transaction/structlog/cold_access_test.go index 11143d3..ea6b87c 100644 --- a/pkg/processor/transaction/structlog/cold_access_test.go +++ b/pkg/processor/transaction/structlog/cold_access_test.go @@ -1,6 +1,7 @@ package structlog import ( + "math" "testing" "github.com/stretchr/testify/assert" @@ -10,6 +11,222 @@ import ( const testNonPrecompileAddr = "0x1234567890123456789012345678901234567890" +// --------------------------------------------------------------------------- +// Helper unit tests +// --------------------------------------------------------------------------- + +func TestIsHexZero(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"empty string", "", true}, + {"single zero", "0", true}, + {"multiple zeros", "0000", true}, + {"with 0x prefix zero", "0x0", true}, + {"with 0x prefix zeros", "0x0000", true}, + {"just 0x prefix", "0x", true}, + {"non-zero single", "1", false}, + {"non-zero with prefix", "0x1", false}, + {"non-zero mixed", "0x00ff00", false}, + {"large non-zero", "de0b6b3a7640000", false}, + {"trailing non-zero", "00000001", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, isHexZero(tc.input)) + }) + } +} + +func TestParseHexUint32(t *testing.T) { + tests := []struct { + name string + input string + expected uint32 + }{ + {"zero", "0", 0}, + {"zero with prefix", "0x0", 0}, + {"small value", "0x20", 32}, + {"max uint32", "0xffffffff", math.MaxUint32}, + {"overflow clamps", "0x100000000", math.MaxUint32}, + {"large overflow", "0xdeadbeefdeadbeef", math.MaxUint32}, + {"empty string", "", 0}, + {"just prefix", "0x", 0}, + {"no prefix", "ff", 255}, + {"malformed", "0xZZZZ", math.MaxUint32}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, parseHexUint32(tc.input)) + }) + } +} + +func TestCallHasValue(t *testing.T) { + tests := []struct { + name string + callTransfersValue bool + stack *[]string + expected bool + }{ + {"embedded true", true, nil, true}, + {"embedded false no stack", false, nil, false}, + {"stack non-zero value", false, &[]string{"0", "0", "0", "0", "1", "addr", "gas"}, true}, + {"stack zero value", false, &[]string{"0", "0", "0", "0", "0", "addr", "gas"}, false}, + {"stack too short", false, &[]string{"gas", "addr"}, false}, + {"stack exactly 3", false, &[]string{"1", "addr", "gas"}, true}, + {"embedded true overrides stack", true, &[]string{"0", "0", "0", "0", "0", "addr", "gas"}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sl := &execution.StructLog{ + Op: "CALL", + CallTransfersValue: tc.callTransfersValue, + Stack: tc.stack, + } + + assert.Equal(t, tc.expected, callHasValue(sl)) + }) + } +} + +func TestExtCodeCopySize(t *testing.T) { + tests := []struct { + name string + embedded uint32 + stack *[]string + expected uint32 + }{ + {"embedded non-zero", 128, nil, 128}, + {"embedded zero with stack", 0, &[]string{"80", "0", "0", "addr"}, 128}, + {"embedded zero no stack", 0, nil, 0}, + {"stack too short", 0, &[]string{"80", "0", "addr"}, 0}, + {"embedded takes priority", 64, &[]string{"80", "0", "0", "addr"}, 64}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sl := &execution.StructLog{ + Op: "EXTCODECOPY", + ExtCodeCopySize: tc.embedded, + Stack: tc.stack, + } + + assert.Equal(t, tc.expected, extCodeCopySize(sl)) + }) + } +} + +func TestGetMemExp(t *testing.T) { + tests := []struct { + name string + slice []uint64 + index int + expected uint64 + }{ + {"nil slice", nil, 0, 0}, + {"index in range", []uint64{10, 20, 30}, 1, 20}, + {"index out of range", []uint64{10, 20}, 5, 0}, + {"index at boundary", []uint64{10, 20}, 2, 0}, + {"first element", []uint64{42}, 0, 42}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, getMemExp(tc.slice, tc.index)) + }) + } +} + +// --------------------------------------------------------------------------- +// Boundary precision tests +// --------------------------------------------------------------------------- + +func TestClassifySload_Boundaries(t *testing.T) { + assert.Equal(t, uint64(0), classifySload(2099), "just below cold threshold") + assert.Equal(t, uint64(1), classifySload(2100), "exact cold threshold") + assert.Equal(t, uint64(1), classifySload(2101), "just above cold threshold") +} + +func TestClassifySstore_Boundaries(t *testing.T) { + // Cold variants are exact matches: 2200, 5000, 22100. + assert.Equal(t, uint64(0), classifySstore(2199)) + assert.Equal(t, uint64(1), classifySstore(2200)) + assert.Equal(t, uint64(0), classifySstore(2201)) + assert.Equal(t, uint64(0), classifySstore(4999)) + assert.Equal(t, uint64(1), classifySstore(5000)) + assert.Equal(t, uint64(0), classifySstore(5001)) + assert.Equal(t, uint64(0), classifySstore(22099)) + assert.Equal(t, uint64(1), classifySstore(22100)) + assert.Equal(t, uint64(0), classifySstore(22101)) +} + +func TestClassifyAccountAccess_Boundaries(t *testing.T) { + assert.Equal(t, uint64(0), classifyAccountAccess(2599), "just below cold threshold") + assert.Equal(t, uint64(1), classifyAccountAccess(2600), "exact cold threshold") + assert.Equal(t, uint64(1), classifyAccountAccess(2601), "just above cold threshold") +} + +func TestClassifyCall_Boundaries(t *testing.T) { + addr := testNonPrecompileAddr + + tests := []struct { + name string + gasSelf uint64 + expected uint64 + }{ + {"at 200 boundary (warm)", 200, 0}, + {"at 201 (gap)", 201, 0}, + {"at 2599 (gap)", 2599, 0}, + {"at 2600 (cold single)", 2600, 1}, + {"at 5199 (cold single)", 5199, 1}, + {"at 5200 (cold double)", 5200, 2}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sl := &execution.StructLog{Op: "STATICCALL", CallToAddress: &addr} + assert.Equal(t, tc.expected, classifyCall(sl, tc.gasSelf, 0)) + }) + } +} + +func TestClassifyCall_MemExpAndValueCombined(t *testing.T) { + // gasSelf=11900, memExp=200, value transfer=9000 → remaining = 11900-200-9000 = 2700 → cold. + addr := testNonPrecompileAddr + sl := &execution.StructLog{Op: "CALL", CallToAddress: &addr, CallTransfersValue: true} + + assert.Equal(t, uint64(1), classifyCall(sl, 11900, 200)) +} + +func TestClassifyExtCodeCopy_Boundaries(t *testing.T) { + // Boundary: remaining must be >= 2600 after subtracting memExp and copyCost. + // gasSelf=2603, copyCost=3 (1 word) → remaining = 2603-3 = 2600 → cold. + sl32 := &execution.StructLog{Op: "EXTCODECOPY", ExtCodeCopySize: 32} + assert.Equal(t, uint64(1), classifyExtCodeCopy(sl32, 2603, 0)) + + // gasSelf=2602, copyCost=3 → remaining = 2599 → warm. + assert.Equal(t, uint64(0), classifyExtCodeCopy(sl32, 2602, 0)) + + // memExp exceeds gasSelf → remaining saturates to 0 → warm. + assert.Equal(t, uint64(0), classifyExtCodeCopy(sl32, 100, 500)) +} + +func TestClassifySelfdestruct_Boundaries(t *testing.T) { + // Exact matches only. + assert.Equal(t, uint64(0), classifySelfdestruct(7599)) + assert.Equal(t, uint64(1), classifySelfdestruct(7600)) + assert.Equal(t, uint64(0), classifySelfdestruct(7601)) + assert.Equal(t, uint64(0), classifySelfdestruct(32599)) + assert.Equal(t, uint64(1), classifySelfdestruct(32600)) + assert.Equal(t, uint64(0), classifySelfdestruct(32601)) +} + func TestClassifyColdAccess_Empty(t *testing.T) { result := ClassifyColdAccess(nil, nil, nil) assert.Nil(t, result) diff --git a/pkg/processor/transaction/structlog/memory_test.go b/pkg/processor/transaction/structlog/memory_test.go index 0a010d9..77de4f3 100644 --- a/pkg/processor/transaction/structlog/memory_test.go +++ b/pkg/processor/transaction/structlog/memory_test.go @@ -1,6 +1,7 @@ package structlog import ( + "math" "testing" "github.com/stretchr/testify/assert" @@ -9,6 +10,100 @@ import ( "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" ) +// --------------------------------------------------------------------------- +// Helper unit tests +// --------------------------------------------------------------------------- + +func TestParseHexUint64(t *testing.T) { + tests := []struct { + name string + input string + expected uint64 + }{ + {"zero", "0", 0}, + {"zero with prefix", "0x0", 0}, + {"small value", "0x20", 32}, + {"large value", "0xde0b6b3a7640000", 1000000000000000000}, + {"max uint64", "0xffffffffffffffff", math.MaxUint64}, + {"overflow returns 0", "0x10000000000000000", 0}, + {"empty string", "", 0}, + {"just prefix", "0x", 0}, + {"no prefix", "ff", 255}, + {"malformed", "0xZZZZ", 0}, + {"single char", "a", 10}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, parseHexUint64(tc.input)) + }) + } +} + +func TestReturnEndBytes(t *testing.T) { + tests := []struct { + name string + stack *[]string + expected uint32 + }{ + {"nil stack", nil, 0}, + {"stack too short", &[]string{"100"}, 0}, + {"zero offset+size", &[]string{"0", "0"}, 0}, + {"simple case", &[]string{"0", "80"}, 128}, + {"offset+size", &[]string{"20", "40"}, 96}, + {"large offset+size", &[]string{"100", "100"}, 512}, + {"offset overflow clamps", &[]string{"ffffffffffffffff", "1"}, math.MaxUint32}, + {"size overflow clamps", &[]string{"1", "ffffffffffffffff"}, math.MaxUint32}, + {"both max clamps", &[]string{"ffffffffffffffff", "ffffffffffffffff"}, math.MaxUint32}, + {"exactly max uint32", &[]string{"0", "ffffffff"}, math.MaxUint32}, + {"stack with extra elements", &[]string{"0", "0", "0", "20", "40"}, 96}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sl := &execution.StructLog{ + Op: "RETURN", + Stack: tc.stack, + } + + assert.Equal(t, tc.expected, returnEndBytes(sl)) + }) + } +} + +func TestComputeMemoryWords_SingleOpcode(t *testing.T) { + // Single opcode trace: wordsBefore from MemorySize, wordsAfter = wordsBefore. + structlogs := []execution.StructLog{ + {Op: "STOP", Depth: 1, MemorySize: 64}, + } + + wb, wa := ComputeMemoryWords(structlogs) + require.NotNil(t, wb) + assert.Equal(t, uint32(2), wb[0]) + assert.Equal(t, uint32(2), wa[0]) +} + +func TestComputeMemoryWords_NestedDepthReturn(t *testing.T) { + // depth 1 -> 2 -> 3 -> 2 -> 1, RETURN at depth 3 expands memory. + stack3 := []string{"0", "100"} // offset=0, size=0x100=256 + + structlogs := []execution.StructLog{ + {Op: "CALL", Depth: 1, MemorySize: 32}, // 1 word + {Op: "CALL", Depth: 2, MemorySize: 0}, // 0 words + {Op: "MSTORE", Depth: 3, MemorySize: 0}, // 0 words + {Op: "RETURN", Depth: 3, MemorySize: 64, Stack: &stack3}, // 2 words, return needs 8 + {Op: "STOP", Depth: 2, MemorySize: 0}, // back to depth 2 + {Op: "STOP", Depth: 1, MemorySize: 32}, // back to depth 1 + } + + wb, wa := ComputeMemoryWords(structlogs) + require.NotNil(t, wb) + + // Op 3 (RETURN, last at depth 3): wordsBefore=2, wordsAfter=ceil(256/32)=8. + assert.Equal(t, uint32(2), wb[3]) + assert.Equal(t, uint32(8), wa[3]) +} + func TestComputeMemoryWords_Empty(t *testing.T) { wb, wa := ComputeMemoryWords(nil) assert.Nil(t, wb) diff --git a/pkg/processor/transaction/structlog_agg/aggregator_test.go b/pkg/processor/transaction/structlog_agg/aggregator_test.go index ea42a62..38eb5d2 100644 --- a/pkg/processor/transaction/structlog_agg/aggregator_test.go +++ b/pkg/processor/transaction/structlog_agg/aggregator_test.go @@ -2,6 +2,7 @@ package structlog_agg import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -892,6 +893,173 @@ func TestFrameAggregator_MultiplePrecompileCalls(t *testing.T) { assert.Equal(t, sha256Addr, *sha256Frame.TargetAddress) } +func TestFrameAggregator_ResourceGasAccumulation(t *testing.T) { + // Verify that the 5 resource gas fields accumulate correctly in per-opcode + // rows and that summary rows SUM across all per-opcode rows. + aggregator := NewFrameAggregator() + + // Three opcodes with varying memory words and cold access counts. + // Op 0: MSTORE, wb=0, wa=1, cold=0 + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "MSTORE", Depth: 1, Gas: 10000, + }, 0, 0, []uint32{0}, 6, 6, nil, nil, 0, 1, 0) + + // Op 1: SLOAD, wb=1, wa=3, cold=1 + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "SLOAD", Depth: 1, Gas: 9994, GasCost: 2100, + }, 1, 0, []uint32{0}, 2100, 2100, nil, &execution.StructLog{Op: "MSTORE", Depth: 1}, 1, 3, 1) + + // Op 2: SLOAD, wb=3, wa=5, cold=0 + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "SLOAD", Depth: 1, Gas: 7894, GasCost: 100, + }, 2, 0, []uint32{0}, 100, 100, nil, &execution.StructLog{Op: "SLOAD", Depth: 1}, 3, 5, 0) + + // Op 3: STOP, wb=5, wa=5, cold=0 + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "STOP", Depth: 1, Gas: 7794, + }, 3, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SLOAD", Depth: 1}, 5, 5, 0) + + trace := &execution.TraceTransaction{Gas: 10000, Failed: false} + frames := aggregator.Finalize(trace, 5000) + + // --- Per-opcode row: MSTORE --- + mstoreRow := getOpcodeRow(frames, 0, "MSTORE") + require.NotNil(t, mstoreRow) + assert.Equal(t, uint64(0), mstoreRow.MemWordsSumBefore) // SUM(wb) = 0 + assert.Equal(t, uint64(1), mstoreRow.MemWordsSumAfter) // SUM(wa) = 1 + assert.Equal(t, uint64(0), mstoreRow.MemWordsSqSumBefore) // SUM(wb²) = 0 + assert.Equal(t, uint64(1), mstoreRow.MemWordsSqSumAfter) // SUM(wa²) = 1 + assert.Equal(t, uint64(0), mstoreRow.ColdAccessCount) + + // --- Per-opcode row: SLOAD (2 invocations) --- + sloadRow := getOpcodeRow(frames, 0, "SLOAD") + require.NotNil(t, sloadRow) + assert.Equal(t, uint64(2), sloadRow.OpcodeCount) + assert.Equal(t, uint64(1+3), sloadRow.MemWordsSumBefore) // SUM(wb) = 1+3 + assert.Equal(t, uint64(3+5), sloadRow.MemWordsSumAfter) // SUM(wa) = 3+5 + assert.Equal(t, uint64(1*1+3*3), sloadRow.MemWordsSqSumBefore) // SUM(wb²) = 1+9 + assert.Equal(t, uint64(3*3+5*5), sloadRow.MemWordsSqSumAfter) // SUM(wa²) = 9+25 + assert.Equal(t, uint64(1), sloadRow.ColdAccessCount) // 1 cold + 0 cold + + // --- Summary row: SUM of all per-opcode rows --- + summaryRow := getSummaryRow(frames, 0) + require.NotNil(t, summaryRow) + + // Sum across MSTORE + SLOAD + STOP + // STOP: wb=5, wa=5, cold=0 + stopRow := getOpcodeRow(frames, 0, "STOP") + require.NotNil(t, stopRow) + + expectedSumBefore := mstoreRow.MemWordsSumBefore + sloadRow.MemWordsSumBefore + stopRow.MemWordsSumBefore + expectedSumAfter := mstoreRow.MemWordsSumAfter + sloadRow.MemWordsSumAfter + stopRow.MemWordsSumAfter + expectedSqSumBefore := mstoreRow.MemWordsSqSumBefore + sloadRow.MemWordsSqSumBefore + stopRow.MemWordsSqSumBefore + expectedSqSumAfter := mstoreRow.MemWordsSqSumAfter + sloadRow.MemWordsSqSumAfter + stopRow.MemWordsSqSumAfter + expectedCold := mstoreRow.ColdAccessCount + sloadRow.ColdAccessCount + stopRow.ColdAccessCount + + assert.Equal(t, expectedSumBefore, summaryRow.MemWordsSumBefore) + assert.Equal(t, expectedSumAfter, summaryRow.MemWordsSumAfter) + assert.Equal(t, expectedSqSumBefore, summaryRow.MemWordsSqSumBefore) + assert.Equal(t, expectedSqSumAfter, summaryRow.MemWordsSqSumAfter) + assert.Equal(t, expectedCold, summaryRow.ColdAccessCount) +} + +func TestFrameAggregator_ResourceGasNestedFrames(t *testing.T) { + // Verify resource gas fields accumulate independently per frame. + aggregator := NewFrameAggregator() + + // Root frame: CALL + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "CALL", Depth: 1, Gas: 10000, + }, 0, 0, []uint32{0}, 5000, 100, nil, nil, 2, 4, 1) + + // Child frame: SLOAD (cold) + callAddr := testAddress + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "SLOAD", Depth: 2, Gas: 5000, GasCost: 2100, + }, 1, 1, []uint32{0, 1}, 2100, 2100, &callAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 1, 1) + + // Child frame: RETURN + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "RETURN", Depth: 2, Gas: 2900, + }, 2, 1, []uint32{0, 1}, 0, 0, nil, &execution.StructLog{Op: "SLOAD", Depth: 2}, 1, 1, 0) + + // Root frame: STOP + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "STOP", Depth: 1, Gas: 5000, + }, 3, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "RETURN", Depth: 2}, 4, 4, 0) + + trace := &execution.TraceTransaction{Gas: 10000, Failed: false} + frames := aggregator.Finalize(trace, 5000) + + // Root summary: CALL(wb=2,wa=4,cold=1) + STOP(wb=4,wa=4,cold=0) + rootSummary := getSummaryRow(frames, 0) + require.NotNil(t, rootSummary) + assert.Equal(t, uint64(2+4), rootSummary.MemWordsSumBefore) + assert.Equal(t, uint64(4+4), rootSummary.MemWordsSumAfter) + assert.Equal(t, uint64(1), rootSummary.ColdAccessCount) + + // Child summary: SLOAD(wb=0,wa=1,cold=1) + RETURN(wb=1,wa=1,cold=0) + childSummary := getSummaryRow(frames, 1) + require.NotNil(t, childSummary) + assert.Equal(t, uint64(0+1), childSummary.MemWordsSumBefore) + assert.Equal(t, uint64(1+1), childSummary.MemWordsSumAfter) + assert.Equal(t, uint64(1), childSummary.ColdAccessCount) +} + +func TestFrameAggregator_ResourceGasZeroValues(t *testing.T) { + // When all resource gas values are 0, fields should remain 0. + aggregator := NewFrameAggregator() + + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "PUSH1", Depth: 1, Gas: 1000, + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "STOP", Depth: 1, Gas: 997, + }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + + trace := &execution.TraceTransaction{Gas: 1000, Failed: false} + frames := aggregator.Finalize(trace, 100) + + summaryRow := getSummaryRow(frames, 0) + require.NotNil(t, summaryRow) + assert.Equal(t, uint64(0), summaryRow.MemWordsSumBefore) + assert.Equal(t, uint64(0), summaryRow.MemWordsSumAfter) + assert.Equal(t, uint64(0), summaryRow.MemWordsSqSumBefore) + assert.Equal(t, uint64(0), summaryRow.MemWordsSqSumAfter) + assert.Equal(t, uint64(0), summaryRow.ColdAccessCount) +} + +func TestFrameAggregator_ResourceGasSyntheticFrameIgnored(t *testing.T) { + // Synthetic frames (op="") should NOT accumulate resource gas into per-opcode stats. + aggregator := NewFrameAggregator() + + eoaAddr := testAddress + + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "CALL", Depth: 1, Gas: 10000, + }, 0, 0, []uint32{0}, 100, 100, nil, nil, 2, 3, 1) + + // Synthetic EOA frame with non-zero resource gas values (passed as 0 in practice, + // but verify the contract: op="" means don't accumulate). + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "", Depth: 2, + }, 0, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) + + aggregator.ProcessStructlog(&execution.StructLog{ + Op: "STOP", Depth: 1, Gas: 9900, + }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 3, 3, 0) + + trace := &execution.TraceTransaction{Gas: 10000, Failed: false} + frames := aggregator.Finalize(trace, 5000) + + // EOA frame should have zero resource gas (no real opcodes). + eoaFrame := getSummaryRow(frames, 1) + require.NotNil(t, eoaFrame) + assert.Equal(t, uint64(0), eoaFrame.MemWordsSumBefore) + assert.Equal(t, uint64(0), eoaFrame.ColdAccessCount) +} + func TestFrameAggregator_PrecompileGasSelfLessThanOverhead(t *testing.T) { // Edge case: gasSelf <= overhead (100). No gas split occurs — // precompileGas stays 0, effectiveGasSelf stays at gasSelf. @@ -936,3 +1104,67 @@ func TestFrameAggregator_PrecompileGasSelfLessThanOverhead(t *testing.T) { require.NotNil(t, precompileFrame) assert.Equal(t, uint64(0), precompileFrame.GasCumulative, "precompile frame gas should be 0") } + +func TestColumns_ResourceGasFields(t *testing.T) { + // Verify that Append stores the 5 resource gas fields and Reset clears them. + cols := NewColumns() + + now := time.Now() + cols.Append( + now, 100, "0xabc", 0, + 0, nil, []uint32{0}, 0, nil, "", "SLOAD", + 1, 0, 2100, 2100, 0, 0, nil, nil, + 10, 20, 100, 400, 3, // resource gas fields + "mainnet", + ) + + assert.Equal(t, 1, cols.Rows()) + assert.Equal(t, uint64(10), cols.MemWordsSumBefore.Row(0)) + assert.Equal(t, uint64(20), cols.MemWordsSumAfter.Row(0)) + assert.Equal(t, uint64(100), cols.MemWordsSqSumBefore.Row(0)) + assert.Equal(t, uint64(400), cols.MemWordsSqSumAfter.Row(0)) + assert.Equal(t, uint64(3), cols.ColdAccessCount.Row(0)) + + // Add a second row and verify independence. + cols.Append( + now, 101, "0xdef", 1, + 1, nil, []uint32{0, 1}, 1, nil, "CALL", "MSTORE", + 2, 0, 6, 6, 1, 1, nil, nil, + 5, 8, 25, 64, 0, + "mainnet", + ) + + assert.Equal(t, 2, cols.Rows()) + assert.Equal(t, uint64(5), cols.MemWordsSumBefore.Row(1)) + assert.Equal(t, uint64(8), cols.MemWordsSumAfter.Row(1)) + assert.Equal(t, uint64(25), cols.MemWordsSqSumBefore.Row(1)) + assert.Equal(t, uint64(64), cols.MemWordsSqSumAfter.Row(1)) + assert.Equal(t, uint64(0), cols.ColdAccessCount.Row(1)) + + // Reset and verify empty. + cols.Reset() + assert.Equal(t, 0, cols.Rows()) +} + +func TestColumns_InputContainsResourceGasFields(t *testing.T) { + // Verify that Input() includes the 5 resource gas column names. + cols := NewColumns() + input := cols.Input() + + expectedNames := []string{ + "memory_words_sum_before", + "memory_words_sum_after", + "memory_words_sq_sum_before", + "memory_words_sq_sum_after", + "cold_access_count", + } + + inputNames := make(map[string]bool, len(input)) + for _, col := range input { + inputNames[col.Name] = true + } + + for _, name := range expectedNames { + assert.True(t, inputNames[name], "Input() should contain column %q", name) + } +} From 25b9506edb9e07b972383ad1375ced5765cc320b Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Mon, 16 Feb 2026 14:41:25 +1000 Subject: [PATCH 5/8] docs(README): add structlog aggregation feature and resource gas decomposition docs --- README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.md b/README.md index 6cd9452..507ac35 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A distributed system for processing Ethereum execution layer data with support f ## Features - **Transaction Structlog Processing**: Extract and store detailed execution traces for every transaction +- **Structlog Aggregation**: Aggregate per-opcode gas data into call frame rows with resource gas decomposition - **Distributed Processing**: Redis-backed task queues with priority-based processing - **Leader Election**: Built-in leader election for coordinated block processing - **Dual Processing Modes**: Forwards (real-time) and backwards (backfill) processing @@ -214,6 +215,42 @@ curl -X POST http://localhost:8080/api/v1/queue/blocks/transaction_structlog \ - Allows reprocessing of already processed blocks - Each API call creates new tasks (calling multiple times will create duplicates) +## Structlog Aggregation + +The `structlog_agg` processor aggregates per-opcode structlog data into call frame rows suitable for ClickHouse storage. It produces two types of rows per call frame: + +- **Summary row** (`operation=""`): Frame-level metadata including gas totals, call type, target address, intrinsic gas, and gas refund +- **Per-opcode rows** (`operation="SLOAD"` etc.): Gas and count aggregated by opcode within each frame + +### Resource Gas Decomposition + +The aggregator computes building-block columns that enable downstream SQL to decompose EVM gas into resource categories (compute, memory, storage access): + +| Column | Description | +|--------|-------------| +| `memory_words_sum_before` | SUM(ceil(memory_bytes/32)) before each opcode | +| `memory_words_sum_after` | SUM(ceil(memory_bytes/32)) after each opcode | +| `memory_words_sq_sum_before` | SUM(words_before^2) for quadratic cost extraction | +| `memory_words_sq_sum_after` | SUM(words_after^2) for quadratic cost extraction | +| `cold_access_count` | Number of cold storage/account accesses (EIP-2929) | + +These columns are computed by two functions in the `structlog` package: + +- **`ComputeMemoryWords`**: Derives per-opcode memory size in 32-byte words using the pending-index technique. Handles depth transitions and RETURN/REVERT last-in-frame expansion via stack operands. +- **`ClassifyColdAccess`**: Classifies each opcode's cold vs warm access using gas values, memory expansion costs, and range-based detection. Supports both embedded mode (pre-computed tracer fields) and RPC mode (stack-based fallbacks). + +### Gas Computation Pipeline + +``` +StructLogs -> ComputeGasUsed -> ComputeGasSelf -> ComputeMemoryWords -> ClassifyColdAccess + | + v + ProcessStructlog (per opcode) + | + v + Finalize -> CallFrameRows +``` + ## Architecture ### Leader Election From f3a24c6cca097501629a687a29436e1df927689b Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Tue, 17 Feb 2026 08:02:02 +1000 Subject: [PATCH 6/8] fix(cold_access): subtract CallNewAccountGas when CALL transfers value to empty account The classifier now removes the 25 000 gas charged for creating a new account before deciding how many cold accesses occurred. This prevents false positives when the large new-account cost would otherwise be mis-classified as extra cold accesses. docs: clarify CALL value-transfer and stipend comments test: add cases covering CALL to empty accounts with value transfer --- .../transaction/structlog/cold_access.go | 18 ++++++++-- .../transaction/structlog/cold_access_test.go | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/pkg/processor/transaction/structlog/cold_access.go b/pkg/processor/transaction/structlog/cold_access.go index 7a8f86f..6c2f53d 100644 --- a/pkg/processor/transaction/structlog/cold_access.go +++ b/pkg/processor/transaction/structlog/cold_access.go @@ -14,7 +14,8 @@ const ( coldSloadCost = 2100 coldAccountCost = 2600 - // CALL value transfer adds 9000 gas (6700 stipend + 2300 callValueTransferGas). + // CALL value transfer: charged when CALL/CALLCODE transfers non-zero ETH. + // The 2300 CallStipend is added to the callee's gas, NOT subtracted from the caller's cost. callValueTransferGas = 9000 // Minimum word copy cost for EXTCODECOPY (3 gas per word). @@ -109,9 +110,9 @@ func classifyAccountAccess(gasCost uint64) uint64 { // classifyCall determines cold access count for CALL-family opcodes. // Normalizes gas by subtracting memory expansion and value transfer costs, // then uses range-based detection: -// - remaining <= 200: 0 cold (warm, possibly with stipend adjustments) +// - remaining <= 200: 0 cold (warm access, possibly with warm delegation) // - remaining 2600-2700: 1 cold (single cold account access) -// - remaining >= 5200: 2 cold (cold account + cold value transfer to new account) +// - remaining >= 5200: 2 cold (cold account + cold EIP-7702 delegation target) func classifyCall(sl *execution.StructLog, gasSelf, memExp uint64) uint64 { remaining := gasSelf @@ -130,6 +131,17 @@ func classifyCall(sl *execution.StructLog, gasSelf, memExp uint64) uint64 { } else { remaining = 0 } + + // Subtract CallNewAccountGas (25000) if remaining is too large. + // This is charged when value > 0 AND the target account is empty. + if remaining > 5200 { + const callNewAccountGas = 25000 + if remaining > callNewAccountGas { + remaining -= callNewAccountGas + } else { + remaining = 0 + } + } } // Precompile targets are always warm (EIP-2929 pre-warms them). diff --git a/pkg/processor/transaction/structlog/cold_access_test.go b/pkg/processor/transaction/structlog/cold_access_test.go index ea6b87c..e964d68 100644 --- a/pkg/processor/transaction/structlog/cold_access_test.go +++ b/pkg/processor/transaction/structlog/cold_access_test.go @@ -365,6 +365,41 @@ func TestClassifyColdAccess_CALL_WithValueTransfer(t *testing.T) { } } +func TestClassifyColdAccess_CALL_NewAccountSubtraction(t *testing.T) { + // CALL with value > 0 to empty account: gasSelf includes 25000 CallNewAccountGas. + // After subtracting memExp and 9000, if remaining > 5200, subtract 25000. + addr := testNonPrecompileAddr + + tests := []struct { + name string + gasSelf uint64 + expected uint64 + }{ + // 34100 - 9000 = 25100 (> 5200 → subtract 25000) = 100 → warm + {"warm new account", 34100, 0}, + // 36600 - 9000 = 27600 (> 5200 → subtract 25000) = 2600 → 1 cold + {"cold new account", 36600, 1}, + // 59200 - 9000 = 50200 (> 5200 → subtract 25000) = 25200 → still > 5200 but this + // shouldn't happen in practice. The remaining 25200 exceeds all classification + // ranges, so it falls into >= 5200 → 2. + {"double cold new account (theoretical)", 59200, 2}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + structlogs := []execution.StructLog{ + { + Op: "CALL", GasCost: tc.gasSelf, Depth: 1, + CallToAddress: &addr, CallTransfersValue: true, + }, + } + + result := ClassifyColdAccess(structlogs, []uint64{tc.gasSelf}, nil) + assert.Equal(t, tc.expected, result[0]) + }) + } +} + func TestClassifyColdAccess_CALL_Precompile(t *testing.T) { // Precompile targets are always warm. precompileAddr := "0x0000000000000000000000000000000000000001" From 17e0446fa845e5e0b0c0f1d58c6f3a384c58b03d Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Tue, 17 Feb 2026 08:16:48 +1000 Subject: [PATCH 7/8] refactor(cold_access): expand inline docs and tighten gas normalization logic - Add detailed inline comments explaining CALL and EXTCODECOPY gas composition, normalization steps, and range-based classification rules. - Clarify why 200/2600/5200 thresholds are safe (gaps >2000 gas). - Document precompile warm-up via EIP-2929 and fallback stack reads. - No functional change; improves future maintainability. --- .../transaction/structlog/cold_access.go | 65 ++++++++++++++----- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/pkg/processor/transaction/structlog/cold_access.go b/pkg/processor/transaction/structlog/cold_access.go index 6c2f53d..611ff70 100644 --- a/pkg/processor/transaction/structlog/cold_access.go +++ b/pkg/processor/transaction/structlog/cold_access.go @@ -108,32 +108,53 @@ func classifyAccountAccess(gasCost uint64) uint64 { } // classifyCall determines cold access count for CALL-family opcodes. -// Normalizes gas by subtracting memory expansion and value transfer costs, -// then uses range-based detection: -// - remaining <= 200: 0 cold (warm access, possibly with warm delegation) -// - remaining 2600-2700: 1 cold (single cold account access) -// - remaining >= 5200: 2 cold (cold account + cold EIP-7702 delegation target) +// +// CALL gas (from Erigon's gas_table.go) is composed of several components: +// +// gasSelf = accessCost (100 warm / 2600 cold) +// + delegationCost (0 / 100 warm / 2600 cold, EIP-7702 only) +// + memExpansion +// + valueTransfer (9000 if value > 0, CALL/CALLCODE only) +// + newAccount (25000 if value > 0 AND target is empty) +// +// To isolate the pure access cost (accessCost + delegationCost), we peel off the +// other components in order. What remains maps to cold count via range-based buckets: +// +// remaining ≤ 200: 0 cold (100 warm, or 100+100 warm+warm delegation) +// 2600–2700: 1 cold (2600 cold, or 100+2600 / 2600+100 mixed) +// ≥ 5200: 2 cold (2600+2600 both cold) +// +// The buckets are safe because there are >2000 gas gaps between them — no valid +// combination of EVM gas values can land in the gaps. +// +// Note: STATICCALL/DELEGATECALL never transfer value, so value normalization +// is skipped for them. func classifyCall(sl *execution.StructLog, gasSelf, memExp uint64) uint64 { remaining := gasSelf - // Subtract memory expansion cost. + // Step 1: Subtract memory expansion cost. if remaining > memExp { remaining -= memExp } else { remaining = 0 } - // Subtract value transfer cost for CALL/CALLCODE with non-zero value. - // Use tracer field if set; otherwise fall back to stack in RPC mode. + // Step 2: Subtract value transfer costs (CALL/CALLCODE only). + // callHasValue checks the embedded tracer field first, then falls back to + // reading the value operand from the RPC stack (stack[len-3]). if (sl.Op == OpcodeCALL || sl.Op == OpcodeCALLCODE) && callHasValue(sl) { + // Subtract CallValueTransferGas (9000): always charged when value > 0. if remaining > callValueTransferGas { remaining -= callValueTransferGas } else { remaining = 0 } - // Subtract CallNewAccountGas (25000) if remaining is too large. - // This is charged when value > 0 AND the target account is empty. + // Subtract CallNewAccountGas (25000): charged when value > 0 AND the + // target account is empty (post-Spurious Dragon). We detect this by + // checking if remaining is still too large after subtracting value + // transfer — if remaining > 5200, there must be a 25000 component + // because no combination of access costs alone can exceed 5200. if remaining > 5200 { const callNewAccountGas = 25000 if remaining > callNewAccountGas { @@ -144,12 +165,13 @@ func classifyCall(sl *execution.StructLog, gasSelf, memExp uint64) uint64 { } } - // Precompile targets are always warm (EIP-2929 pre-warms them). + // Precompile targets are always warm (EIP-2929 pre-warms them in the + // access list before execution begins). if sl.CallToAddress != nil && IsPrecompile(*sl.CallToAddress) { return 0 } - // Range-based classification. + // Step 3: Range-based classification on the remaining pure access cost. if remaining <= 200 { return 0 } @@ -198,18 +220,30 @@ func extCodeCopySize(sl *execution.StructLog) uint32 { } // classifyExtCodeCopy determines cold access count for EXTCODECOPY. -// Normalizes gas by subtracting memory expansion and copy costs. +// +// EXTCODECOPY gas is composed of: +// +// gasSelf = accessCost (100 warm / 2600 cold) + memExpansion + copyCost +// +// where copyCost = 3 * ceil(size / 32) — 3 gas per 32-byte word of the requested +// copy size. The EVM charges based on the requested size, not the actual code +// length (zero-pads if requested > actual). +// +// After subtracting memExpansion and copyCost, remaining >= 2600 indicates cold. +// Unlike CALL family, EXTCODECOPY has no EIP-7702 delegation interaction, so cold +// count is always 0 or 1. func classifyExtCodeCopy(sl *execution.StructLog, gasSelf, memExp uint64) uint64 { remaining := gasSelf - // Subtract memory expansion cost. + // Step 1: Subtract memory expansion cost. if remaining > memExp { remaining -= memExp } else { remaining = 0 } - // Subtract copy cost: 3 gas per 32-byte word (rounded up). + // Step 2: Subtract copy cost. The size operand comes from the embedded tracer + // field (ExtCodeCopySize) or the RPC stack (stack[len-4]) as fallback. size := extCodeCopySize(sl) copyWords := (uint64(size) + 31) / 32 copyCost := copyWords * wordCopyCost @@ -220,6 +254,7 @@ func classifyExtCodeCopy(sl *execution.StructLog, gasSelf, memExp uint64) uint64 remaining = 0 } + // Step 3: What remains is the pure access cost. if remaining >= coldAccountCost { return 1 } From 57f9acf7eb85b90d765146d3059a9958e06e0b44 Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Tue, 17 Feb 2026 11:40:44 +1000 Subject: [PATCH 8/8] fix(cold_access): detect cold SLOAD and account access when gas is capped feat(aggregator): add exact memory expansion gas tracking per opcode refactor: replace hard-coded thresholds with warmAccessCost constant --- .../transaction/structlog/cold_access.go | 14 +- .../transaction/structlog/cold_access_test.go | 21 ++- .../transaction/structlog_agg/aggregator.go | 7 + .../structlog_agg/aggregator_test.go | 122 +++++++++--------- .../transaction/structlog_agg/columns.go | 5 + .../transaction/structlog_agg/processor.go | 1 + .../structlog_agg/transaction_processing.go | 9 +- 7 files changed, 108 insertions(+), 71 deletions(-) diff --git a/pkg/processor/transaction/structlog/cold_access.go b/pkg/processor/transaction/structlog/cold_access.go index 611ff70..de0c422 100644 --- a/pkg/processor/transaction/structlog/cold_access.go +++ b/pkg/processor/transaction/structlog/cold_access.go @@ -77,9 +77,12 @@ func classifyOpcode(sl *execution.StructLog, gasSelf, memExp uint64) uint64 { } } -// classifySload: cold if GasCost >= 2100 (cold SLOAD cost). +// classifySload: cold if GasCost > 100 (warm access cost). +// SLOAD costs exactly 100 (warm) or 2100 (cold). Any value above 100 but +// below 2100 indicates a cold SLOAD whose GasCost was capped at remaining +// gas by the tracer due to out-of-gas. func classifySload(gasCost uint64) uint64 { - if gasCost >= coldSloadCost { + if gasCost > warmAccessCost { return 1 } @@ -98,9 +101,12 @@ func classifySstore(gasCost uint64) uint64 { } } -// classifyAccountAccess: cold if GasCost >= 2600 (cold account access cost). +// classifyAccountAccess: cold if GasCost > 100 (warm access cost). +// BALANCE/EXTCODESIZE/EXTCODEHASH cost exactly 100 (warm) or 2600 (cold). +// Any value above 100 but below 2600 indicates a cold access whose GasCost +// was capped at remaining gas by the tracer due to out-of-gas. func classifyAccountAccess(gasCost uint64) uint64 { - if gasCost >= coldAccountCost { + if gasCost > warmAccessCost { return 1 } diff --git a/pkg/processor/transaction/structlog/cold_access_test.go b/pkg/processor/transaction/structlog/cold_access_test.go index e964d68..b4ecce1 100644 --- a/pkg/processor/transaction/structlog/cold_access_test.go +++ b/pkg/processor/transaction/structlog/cold_access_test.go @@ -148,9 +148,13 @@ func TestGetMemExp(t *testing.T) { // --------------------------------------------------------------------------- func TestClassifySload_Boundaries(t *testing.T) { - assert.Equal(t, uint64(0), classifySload(2099), "just below cold threshold") - assert.Equal(t, uint64(1), classifySload(2100), "exact cold threshold") - assert.Equal(t, uint64(1), classifySload(2101), "just above cold threshold") + assert.Equal(t, uint64(0), classifySload(99), "below warm cost") + assert.Equal(t, uint64(0), classifySload(100), "exact warm cost") + assert.Equal(t, uint64(1), classifySload(101), "just above warm cost") + assert.Equal(t, uint64(1), classifySload(1756), "OOG-capped cold SLOAD") + assert.Equal(t, uint64(1), classifySload(2099), "just below cold cost") + assert.Equal(t, uint64(1), classifySload(2100), "exact cold cost") + assert.Equal(t, uint64(1), classifySload(2101), "just above cold cost") } func TestClassifySstore_Boundaries(t *testing.T) { @@ -167,9 +171,13 @@ func TestClassifySstore_Boundaries(t *testing.T) { } func TestClassifyAccountAccess_Boundaries(t *testing.T) { - assert.Equal(t, uint64(0), classifyAccountAccess(2599), "just below cold threshold") - assert.Equal(t, uint64(1), classifyAccountAccess(2600), "exact cold threshold") - assert.Equal(t, uint64(1), classifyAccountAccess(2601), "just above cold threshold") + assert.Equal(t, uint64(0), classifyAccountAccess(99), "below warm cost") + assert.Equal(t, uint64(0), classifyAccountAccess(100), "exact warm cost") + assert.Equal(t, uint64(1), classifyAccountAccess(101), "just above warm cost") + assert.Equal(t, uint64(1), classifyAccountAccess(1500), "OOG-capped cold access") + assert.Equal(t, uint64(1), classifyAccountAccess(2599), "just below cold cost") + assert.Equal(t, uint64(1), classifyAccountAccess(2600), "exact cold cost") + assert.Equal(t, uint64(1), classifyAccountAccess(2601), "just above cold cost") } func TestClassifyCall_Boundaries(t *testing.T) { @@ -239,6 +247,7 @@ func TestClassifyColdAccess_SLOAD(t *testing.T) { expected uint64 }{ {"warm SLOAD (100)", 100, 0}, + {"OOG-capped cold SLOAD (1756)", 1756, 1}, {"cold SLOAD (2100)", 2100, 1}, {"cold SLOAD (2200 - with extra)", 2200, 1}, } diff --git a/pkg/processor/transaction/structlog_agg/aggregator.go b/pkg/processor/transaction/structlog_agg/aggregator.go index d5820a7..a9a4b74 100644 --- a/pkg/processor/transaction/structlog_agg/aggregator.go +++ b/pkg/processor/transaction/structlog_agg/aggregator.go @@ -31,6 +31,7 @@ type CallFrameRow struct { MemWordsSumAfter uint64 MemWordsSqSumBefore uint64 MemWordsSqSumAfter uint64 + MemExpansionGas uint64 // SUM(memory_expansion_gas) — exact per-opcode memory expansion cost ColdAccessCount uint64 } @@ -48,6 +49,7 @@ type OpcodeStats struct { MemWordsSumAfter uint64 // SUM(memory_words_after) MemWordsSqSumBefore uint64 // SUM(memory_words_before²) MemWordsSqSumAfter uint64 // SUM(memory_words_after²) + MemExpansionGas uint64 // SUM(memory_expansion_gas) per opcode ColdCount uint64 // Number of cold accesses } @@ -96,6 +98,7 @@ func NewFrameAggregator() *FrameAggregator { // - prevStructlog: Previous structlog (for detecting frame entry via CALL/CREATE) // - memWordsBefore: Memory words before this opcode (0 if unavailable) // - memWordsAfter: Memory words after this opcode (0 if unavailable) +// - memExpansionGas: Memory expansion gas for this opcode (exact per-opcode cost) // - coldAccessCount: Number of cold accesses for this opcode (0, 1, or 2) func (fa *FrameAggregator) ProcessStructlog( sl *execution.StructLog, @@ -108,6 +111,7 @@ func (fa *FrameAggregator) ProcessStructlog( prevStructlog *execution.StructLog, memWordsBefore uint32, memWordsAfter uint32, + memExpansionGas uint64, coldAccessCount uint64, ) { acc, exists := fa.frames[frameID] @@ -169,6 +173,7 @@ func (fa *FrameAggregator) ProcessStructlog( stats.MemWordsSumAfter += wa stats.MemWordsSqSumBefore += wb * wb stats.MemWordsSqSumAfter += wa * wa + stats.MemExpansionGas += memExpansionGas stats.ColdCount += coldAccessCount // Track min/max depth @@ -325,6 +330,7 @@ func (fa *FrameAggregator) Finalize(trace *execution.TraceTransaction, receiptGa summaryRow.MemWordsSumAfter += stats.MemWordsSumAfter summaryRow.MemWordsSqSumBefore += stats.MemWordsSqSumBefore summaryRow.MemWordsSqSumAfter += stats.MemWordsSqSumAfter + summaryRow.MemExpansionGas += stats.MemExpansionGas summaryRow.ColdAccessCount += stats.ColdCount } @@ -352,6 +358,7 @@ func (fa *FrameAggregator) Finalize(trace *execution.TraceTransaction, receiptGa MemWordsSumAfter: stats.MemWordsSumAfter, MemWordsSqSumBefore: stats.MemWordsSqSumBefore, MemWordsSqSumAfter: stats.MemWordsSqSumAfter, + MemExpansionGas: stats.MemExpansionGas, ColdAccessCount: stats.ColdCount, } rows = append(rows, opcodeRow) diff --git a/pkg/processor/transaction/structlog_agg/aggregator_test.go b/pkg/processor/transaction/structlog_agg/aggregator_test.go index 38eb5d2..9370ec2 100644 --- a/pkg/processor/transaction/structlog_agg/aggregator_test.go +++ b/pkg/processor/transaction/structlog_agg/aggregator_test.go @@ -83,7 +83,7 @@ func TestFrameAggregator_SingleFrame(t *testing.T) { } // For simple opcodes, gasSelf == gasUsed - aggregator.ProcessStructlog(execSl, i, 0, framePath, sl.gasUsed, sl.gasUsed, nil, prevSl, 0, 0, 0) + aggregator.ProcessStructlog(execSl, i, 0, framePath, sl.gasUsed, sl.gasUsed, nil, prevSl, 0, 0, 0, 0) } trace := &execution.TraceTransaction{ @@ -128,14 +128,14 @@ func TestFrameAggregator_NestedCalls(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) // CALL opcode: gasUsed includes child gas, gasSelf is just the CALL overhead aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 9997, - }, 1, 0, []uint32{0}, 5000, 100, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 5000, 100, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // Frame 1 (child) - depth 2 callAddr := testAddress @@ -144,20 +144,20 @@ func TestFrameAggregator_NestedCalls(t *testing.T) { Op: "PUSH1", Depth: 2, Gas: 5000, - }, 2, 1, []uint32{0, 1}, 3, 3, &callAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) + }, 2, 1, []uint32{0, 1}, 3, 3, &callAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "RETURN", Depth: 2, Gas: 4997, - }, 3, 1, []uint32{0, 1}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 2}, 0, 0, 0) + }, 3, 1, []uint32{0, 1}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 2}, 0, 0, 0, 0) // Back to root frame aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 4997, - }, 4, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "RETURN", Depth: 2}, 0, 0, 0) + }, 4, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "RETURN", Depth: 2}, 0, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 10000, @@ -201,14 +201,14 @@ func TestFrameAggregator_ErrorCounting(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 1000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "REVERT", Depth: 1, Gas: 997, Error: &errMsg, - }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 1000, @@ -383,13 +383,13 @@ func TestFrameAggregator_EOAFrame(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 9997, - }, 1, 0, []uint32{0}, 100, 100, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 100, 100, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // Synthetic EOA frame (operation = "", depth = 2) eoaAddr := "0xEOAEOAEOAEOAEOAEOAEOAEOAEOAEOAEOAEOAEOAE" @@ -398,14 +398,14 @@ func TestFrameAggregator_EOAFrame(t *testing.T) { Op: "", // Empty = synthetic EOA row Depth: 2, Gas: 0, - }, 1, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) + }, 1, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0, 0) // Back to root frame aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 9897, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 10000, @@ -442,7 +442,7 @@ func TestFrameAggregator_SetRootTargetAddress(t *testing.T) { Op: "STOP", Depth: 1, Gas: 1000, - }, 0, 0, []uint32{0}, 0, 0, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 0, 0, nil, nil, 0, 0, 0, 0) // Set root target address (simulating tx.To()) rootAddr := testAddress @@ -478,7 +478,7 @@ func TestFrameAggregator_FailedTransaction_NoRefundButHasIntrinsic(t *testing.T) Op: "PUSH1", Depth: 1, Gas: 80000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) // SSTORE that generates a refund aggregator.ProcessStructlog(&execution.StructLog{ @@ -486,7 +486,7 @@ func TestFrameAggregator_FailedTransaction_NoRefundButHasIntrinsic(t *testing.T) Depth: 1, Gas: 79997, Refund: &refundValue, // Refund accumulated - }, 1, 0, []uint32{0}, 20000, 20000, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 20000, 20000, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // Transaction fails with REVERT aggregator.ProcessStructlog(&execution.StructLog{ @@ -495,7 +495,7 @@ func TestFrameAggregator_FailedTransaction_NoRefundButHasIntrinsic(t *testing.T) Gas: 59997, Error: &errMsg, Refund: &refundValue, // Refund still present but won't be applied - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SSTORE", Depth: 1}, 0, 0, 0) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SSTORE", Depth: 1}, 0, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 80000, @@ -534,7 +534,7 @@ func TestFrameAggregator_SuccessfulTransaction_HasRefundAndIntrinsic(t *testing. Op: "PUSH1", Depth: 1, Gas: 80000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) // SSTORE that generates a refund aggregator.ProcessStructlog(&execution.StructLog{ @@ -542,7 +542,7 @@ func TestFrameAggregator_SuccessfulTransaction_HasRefundAndIntrinsic(t *testing. Depth: 1, Gas: 79997, Refund: &refundValue, - }, 1, 0, []uint32{0}, 20000, 20000, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 20000, 20000, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // Successful STOP aggregator.ProcessStructlog(&execution.StructLog{ @@ -550,7 +550,7 @@ func TestFrameAggregator_SuccessfulTransaction_HasRefundAndIntrinsic(t *testing. Depth: 1, Gas: 59997, Refund: &refundValue, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SSTORE", Depth: 1}, 0, 0, 0) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SSTORE", Depth: 1}, 0, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 80000, @@ -593,7 +593,7 @@ func TestFrameAggregator_RevertWithoutOpcodeError(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 50000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) // REVERT opcode with NO error field (realistic behavior) aggregator.ProcessStructlog(&execution.StructLog{ @@ -601,7 +601,7 @@ func TestFrameAggregator_RevertWithoutOpcodeError(t *testing.T) { Depth: 1, Gas: 49997, // Note: NO Error field set - REVERT executes successfully - }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // trace.Failed is true because the transaction reverted trace := &execution.TraceTransaction{ @@ -662,7 +662,7 @@ func TestFrameAggregator_PrecompileFrame(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) // CALL to precompile: gasSelf=3100 (100 overhead + 3000 precompile execution). // With precompile gas extraction: @@ -672,20 +672,20 @@ func TestFrameAggregator_PrecompileFrame(t *testing.T) { Op: "CALL", Depth: 1, Gas: 9997, - }, 1, 0, []uint32{0}, 3100, 100, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 3100, 100, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // Synthetic precompile frame (gas = precompileGas = 3000) aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 1, 1, []uint32{0, 1}, 3000, 3000, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) + }, 1, 1, []uint32{0, 1}, 3000, 3000, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0, 0) // Back to root frame aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 6897, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0, 0) trace := &execution.TraceTransaction{ Gas: 10000, @@ -736,26 +736,26 @@ func TestFrameAggregator_PrecompileGasSplitInvariant(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 20000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) // CALL with effectiveGasSelf = overhead aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 19997, - }, 1, 0, []uint32{0}, originalGasSelf, overhead, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, originalGasSelf, overhead, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // Synthetic precompile frame aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 1, 1, []uint32{0, 1}, precompileGas, precompileGas, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) + }, 1, 1, []uint32{0, 1}, precompileGas, precompileGas, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 14897, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0, 0) trace := &execution.TraceTransaction{Gas: 20000, Failed: false} frames := aggregator.Finalize(trace, 10000) @@ -782,26 +782,26 @@ func TestFrameAggregator_EOAFrameUnchanged(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) // CALL to EOA: gasSelf=100, no precompile gas extraction aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 9997, - }, 1, 0, []uint32{0}, 100, 100, &eoaAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 100, 100, &eoaAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // Synthetic EOA frame (gas = 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 1, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) + }, 1, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 9897, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0, 0) trace := &execution.TraceTransaction{Gas: 10000, Failed: false} frames := aggregator.Finalize(trace, 5000) @@ -831,46 +831,46 @@ func TestFrameAggregator_MultiplePrecompileCalls(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 50000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) // First precompile call: ecrecover (gas = 3100 = 100 + 3000) aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 49997, - }, 1, 0, []uint32{0}, 3100, 100, &ecrecoverAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 3100, 100, &ecrecoverAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // Synthetic frame for ecrecover aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 1, 1, []uint32{0, 1}, 3000, 3000, &ecrecoverAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) + }, 1, 1, []uint32{0, 1}, 3000, 3000, &ecrecoverAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0, 0) // Some opcodes between the two precompile calls aggregator.ProcessStructlog(&execution.StructLog{ Op: "PUSH1", Depth: 1, Gas: 46897, - }, 2, 0, []uint32{0}, 3, 3, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) + }, 2, 0, []uint32{0}, 3, 3, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0, 0) // Second precompile call: sha256 (gas = 1100 = 100 + 1000) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STATICCALL", Depth: 1, Gas: 46894, - }, 3, 0, []uint32{0}, 1100, 100, &sha256Addr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 3, 0, []uint32{0}, 1100, 100, &sha256Addr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // Synthetic frame for sha256 aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 3, 2, []uint32{0, 2}, 1000, 1000, &sha256Addr, &execution.StructLog{Op: "STATICCALL", Depth: 1}, 0, 0, 0) + }, 3, 2, []uint32{0, 2}, 1000, 1000, &sha256Addr, &execution.StructLog{Op: "STATICCALL", Depth: 1}, 0, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 45794, - }, 4, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) + }, 4, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0, 0) trace := &execution.TraceTransaction{Gas: 50000, Failed: false} frames := aggregator.Finalize(trace, 30000) @@ -902,22 +902,22 @@ func TestFrameAggregator_ResourceGasAccumulation(t *testing.T) { // Op 0: MSTORE, wb=0, wa=1, cold=0 aggregator.ProcessStructlog(&execution.StructLog{ Op: "MSTORE", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 6, 6, nil, nil, 0, 1, 0) + }, 0, 0, []uint32{0}, 6, 6, nil, nil, 0, 1, 0, 0) // Op 1: SLOAD, wb=1, wa=3, cold=1 aggregator.ProcessStructlog(&execution.StructLog{ Op: "SLOAD", Depth: 1, Gas: 9994, GasCost: 2100, - }, 1, 0, []uint32{0}, 2100, 2100, nil, &execution.StructLog{Op: "MSTORE", Depth: 1}, 1, 3, 1) + }, 1, 0, []uint32{0}, 2100, 2100, nil, &execution.StructLog{Op: "MSTORE", Depth: 1}, 1, 3, 0, 1) // Op 2: SLOAD, wb=3, wa=5, cold=0 aggregator.ProcessStructlog(&execution.StructLog{ Op: "SLOAD", Depth: 1, Gas: 7894, GasCost: 100, - }, 2, 0, []uint32{0}, 100, 100, nil, &execution.StructLog{Op: "SLOAD", Depth: 1}, 3, 5, 0) + }, 2, 0, []uint32{0}, 100, 100, nil, &execution.StructLog{Op: "SLOAD", Depth: 1}, 3, 5, 0, 0) // Op 3: STOP, wb=5, wa=5, cold=0 aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 7794, - }, 3, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SLOAD", Depth: 1}, 5, 5, 0) + }, 3, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "SLOAD", Depth: 1}, 5, 5, 0, 0) trace := &execution.TraceTransaction{Gas: 10000, Failed: false} frames := aggregator.Finalize(trace, 5000) @@ -955,12 +955,14 @@ func TestFrameAggregator_ResourceGasAccumulation(t *testing.T) { expectedSqSumBefore := mstoreRow.MemWordsSqSumBefore + sloadRow.MemWordsSqSumBefore + stopRow.MemWordsSqSumBefore expectedSqSumAfter := mstoreRow.MemWordsSqSumAfter + sloadRow.MemWordsSqSumAfter + stopRow.MemWordsSqSumAfter expectedCold := mstoreRow.ColdAccessCount + sloadRow.ColdAccessCount + stopRow.ColdAccessCount + expectedMemExp := mstoreRow.MemExpansionGas + sloadRow.MemExpansionGas + stopRow.MemExpansionGas assert.Equal(t, expectedSumBefore, summaryRow.MemWordsSumBefore) assert.Equal(t, expectedSumAfter, summaryRow.MemWordsSumAfter) assert.Equal(t, expectedSqSumBefore, summaryRow.MemWordsSqSumBefore) assert.Equal(t, expectedSqSumAfter, summaryRow.MemWordsSqSumAfter) assert.Equal(t, expectedCold, summaryRow.ColdAccessCount) + assert.Equal(t, expectedMemExp, summaryRow.MemExpansionGas) } func TestFrameAggregator_ResourceGasNestedFrames(t *testing.T) { @@ -970,23 +972,23 @@ func TestFrameAggregator_ResourceGasNestedFrames(t *testing.T) { // Root frame: CALL aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 5000, 100, nil, nil, 2, 4, 1) + }, 0, 0, []uint32{0}, 5000, 100, nil, nil, 2, 4, 0, 1) // Child frame: SLOAD (cold) callAddr := testAddress aggregator.ProcessStructlog(&execution.StructLog{ Op: "SLOAD", Depth: 2, Gas: 5000, GasCost: 2100, - }, 1, 1, []uint32{0, 1}, 2100, 2100, &callAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 1, 1) + }, 1, 1, []uint32{0, 1}, 2100, 2100, &callAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 1, 0, 1) // Child frame: RETURN aggregator.ProcessStructlog(&execution.StructLog{ Op: "RETURN", Depth: 2, Gas: 2900, - }, 2, 1, []uint32{0, 1}, 0, 0, nil, &execution.StructLog{Op: "SLOAD", Depth: 2}, 1, 1, 0) + }, 2, 1, []uint32{0, 1}, 0, 0, nil, &execution.StructLog{Op: "SLOAD", Depth: 2}, 1, 1, 0, 0) // Root frame: STOP aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 5000, - }, 3, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "RETURN", Depth: 2}, 4, 4, 0) + }, 3, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "RETURN", Depth: 2}, 4, 4, 0, 0) trace := &execution.TraceTransaction{Gas: 10000, Failed: false} frames := aggregator.Finalize(trace, 5000) @@ -1012,11 +1014,11 @@ func TestFrameAggregator_ResourceGasZeroValues(t *testing.T) { aggregator.ProcessStructlog(&execution.StructLog{ Op: "PUSH1", Depth: 1, Gas: 1000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 997, - }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) trace := &execution.TraceTransaction{Gas: 1000, Failed: false} frames := aggregator.Finalize(trace, 100) @@ -1027,6 +1029,7 @@ func TestFrameAggregator_ResourceGasZeroValues(t *testing.T) { assert.Equal(t, uint64(0), summaryRow.MemWordsSumAfter) assert.Equal(t, uint64(0), summaryRow.MemWordsSqSumBefore) assert.Equal(t, uint64(0), summaryRow.MemWordsSqSumAfter) + assert.Equal(t, uint64(0), summaryRow.MemExpansionGas) assert.Equal(t, uint64(0), summaryRow.ColdAccessCount) } @@ -1038,17 +1041,17 @@ func TestFrameAggregator_ResourceGasSyntheticFrameIgnored(t *testing.T) { aggregator.ProcessStructlog(&execution.StructLog{ Op: "CALL", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 100, 100, nil, nil, 2, 3, 1) + }, 0, 0, []uint32{0}, 100, 100, nil, nil, 2, 3, 0, 1) // Synthetic EOA frame with non-zero resource gas values (passed as 0 in practice, // but verify the contract: op="" means don't accumulate). aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 0, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) + }, 0, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 9900, - }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 3, 3, 0) + }, 1, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 3, 3, 0, 0) trace := &execution.TraceTransaction{Gas: 10000, Failed: false} frames := aggregator.Finalize(trace, 5000) @@ -1071,7 +1074,7 @@ func TestFrameAggregator_PrecompileGasSelfLessThanOverhead(t *testing.T) { Op: "PUSH1", Depth: 1, Gas: 10000, - }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0) + }, 0, 0, []uint32{0}, 3, 3, nil, nil, 0, 0, 0, 0) // CALL to precompile with gasSelf=50 (less than overhead=100) // This shouldn't split — effectiveGasSelf stays 50 @@ -1079,19 +1082,19 @@ func TestFrameAggregator_PrecompileGasSelfLessThanOverhead(t *testing.T) { Op: "CALL", Depth: 1, Gas: 9997, - }, 1, 0, []uint32{0}, 50, 50, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0) + }, 1, 0, []uint32{0}, 50, 50, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1}, 0, 0, 0, 0) // Synthetic frame with gas=0 (no precompile gas extracted) aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: 2, - }, 1, 1, []uint32{0, 1}, 0, 0, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0) + }, 1, 1, []uint32{0, 1}, 0, 0, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1}, 0, 0, 0, 0) aggregator.ProcessStructlog(&execution.StructLog{ Op: "STOP", Depth: 1, Gas: 9947, - }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0) + }, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2}, 0, 0, 0, 0) trace := &execution.TraceTransaction{Gas: 10000, Failed: false} frames := aggregator.Finalize(trace, 5000) @@ -1114,7 +1117,7 @@ func TestColumns_ResourceGasFields(t *testing.T) { now, 100, "0xabc", 0, 0, nil, []uint32{0}, 0, nil, "", "SLOAD", 1, 0, 2100, 2100, 0, 0, nil, nil, - 10, 20, 100, 400, 3, // resource gas fields + 10, 20, 100, 400, 0, 3, // resource gas fields "mainnet", ) @@ -1130,7 +1133,7 @@ func TestColumns_ResourceGasFields(t *testing.T) { now, 101, "0xdef", 1, 1, nil, []uint32{0, 1}, 1, nil, "CALL", "MSTORE", 2, 0, 6, 6, 1, 1, nil, nil, - 5, 8, 25, 64, 0, + 5, 8, 25, 64, 0, 0, "mainnet", ) @@ -1156,6 +1159,7 @@ func TestColumns_InputContainsResourceGasFields(t *testing.T) { "memory_words_sum_after", "memory_words_sq_sum_before", "memory_words_sq_sum_after", + "memory_expansion_gas", "cold_access_count", } diff --git a/pkg/processor/transaction/structlog_agg/columns.go b/pkg/processor/transaction/structlog_agg/columns.go index 4165d19..b98e306 100644 --- a/pkg/processor/transaction/structlog_agg/columns.go +++ b/pkg/processor/transaction/structlog_agg/columns.go @@ -44,6 +44,7 @@ type Columns struct { MemWordsSumAfter proto.ColUInt64 MemWordsSqSumBefore proto.ColUInt64 MemWordsSqSumAfter proto.ColUInt64 + MemExpansionGas proto.ColUInt64 ColdAccessCount proto.ColUInt64 MetaNetworkName proto.ColStr } @@ -84,6 +85,7 @@ func (c *Columns) Append( memWordsSumAfter uint64, memWordsSqSumBefore uint64, memWordsSqSumAfter uint64, + memExpansionGas uint64, coldAccessCount uint64, network string, ) { @@ -110,6 +112,7 @@ func (c *Columns) Append( c.MemWordsSumAfter.Append(memWordsSumAfter) c.MemWordsSqSumBefore.Append(memWordsSqSumBefore) c.MemWordsSqSumAfter.Append(memWordsSqSumAfter) + c.MemExpansionGas.Append(memExpansionGas) c.ColdAccessCount.Append(coldAccessCount) c.MetaNetworkName.Append(network) } @@ -139,6 +142,7 @@ func (c *Columns) Reset() { c.MemWordsSumAfter.Reset() c.MemWordsSqSumBefore.Reset() c.MemWordsSqSumAfter.Reset() + c.MemExpansionGas.Reset() c.ColdAccessCount.Reset() c.MetaNetworkName.Reset() } @@ -169,6 +173,7 @@ func (c *Columns) Input() proto.Input { {Name: "memory_words_sum_after", Data: &c.MemWordsSumAfter}, {Name: "memory_words_sq_sum_before", Data: &c.MemWordsSqSumBefore}, {Name: "memory_words_sq_sum_after", Data: &c.MemWordsSqSumAfter}, + {Name: "memory_expansion_gas", Data: &c.MemExpansionGas}, {Name: "cold_access_count", Data: &c.ColdAccessCount}, {Name: "meta_network_name", Data: &c.MetaNetworkName}, } diff --git a/pkg/processor/transaction/structlog_agg/processor.go b/pkg/processor/transaction/structlog_agg/processor.go index 2147210..a39d044 100644 --- a/pkg/processor/transaction/structlog_agg/processor.go +++ b/pkg/processor/transaction/structlog_agg/processor.go @@ -319,6 +319,7 @@ func (p *Processor) flushRows(ctx context.Context, rows []insertRow) error { row.Frame.MemWordsSumAfter, row.Frame.MemWordsSqSumBefore, row.Frame.MemWordsSqSumAfter, + row.Frame.MemExpansionGas, row.Frame.ColdAccessCount, row.Network, ) diff --git a/pkg/processor/transaction/structlog_agg/transaction_processing.go b/pkg/processor/transaction/structlog_agg/transaction_processing.go index 81f3929..2dd8e9e 100644 --- a/pkg/processor/transaction/structlog_agg/transaction_processing.go +++ b/pkg/processor/transaction/structlog_agg/transaction_processing.go @@ -178,12 +178,17 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc wa = wordsAfter[i] } + var memExp uint64 + if memExpGas != nil { + memExp = memExpGas[i] + } + if coldCounts != nil { cold = coldCounts[i] } // Process this structlog into the aggregator with (possibly reduced) gasSelf - aggregator.ProcessStructlog(sl, i, frameID, framePath, gasUsed[i], effectiveGasSelf, callToAddr, prevStructlog, wb, wa, cold) + aggregator.ProcessStructlog(sl, i, frameID, framePath, gasUsed[i], effectiveGasSelf, callToAddr, prevStructlog, wb, wa, memExp, cold) // Emit synthetic frame for ALL immediate-return CALLs (EOA + precompile). // For EOA calls: precompileGas = 0, so frame has gas=0 (unchanged behavior). @@ -196,7 +201,7 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc aggregator.ProcessStructlog(&execution.StructLog{ Op: "", Depth: sl.Depth + 1, - }, i, synthFrameID, synthFramePath, precompileGas, precompileGas, callToAddr, sl, 0, 0, 0) + }, i, synthFrameID, synthFramePath, precompileGas, precompileGas, callToAddr, sl, 0, 0, 0, 0) } } }