diff --git a/pkg/processor/manager.go b/pkg/processor/manager.go index a6dc920..3b21ab8 100644 --- a/pkg/processor/manager.go +++ b/pkg/processor/manager.go @@ -945,7 +945,7 @@ func (m *Manager) shouldSkipBlockProcessing(ctx context.Context) (bool, string) // GetQueueName returns the current queue name based on processing mode. func (m *Manager) GetQueueName() string { // For now we only have one processor - processorName := "transaction-structlog" + processorName := "transaction_structlog" if m.config.Mode == c.BACKWARDS_MODE { return c.PrefixedProcessBackwardsQueue(processorName, m.redisPrefix) } diff --git a/pkg/processor/transaction/structlog/call_tracker.go b/pkg/processor/transaction/structlog/call_tracker.go new file mode 100644 index 0000000..1ba386a --- /dev/null +++ b/pkg/processor/transaction/structlog/call_tracker.go @@ -0,0 +1,66 @@ +package structlog + +// CallFrame represents a single call frame in the EVM execution. +type CallFrame struct { + ID uint32 // Sequential frame ID within the transaction + Depth uint64 // EVM depth level +} + +// CallTracker tracks call frames during EVM opcode traversal. +// It assigns sequential frame IDs as calls are entered and maintains +// the current path from root to the active frame. +type CallTracker struct { + stack []CallFrame // Stack of active call frames + nextID uint32 // Next frame ID to assign + path []uint32 // Current path from root to active frame +} + +// NewCallTracker creates a new CallTracker initialized with the root frame. +// The root frame has ID 0 and Depth 1, matching EVM structlog traces where +// execution starts at depth 1 (not 0). +func NewCallTracker() *CallTracker { + return &CallTracker{ + stack: []CallFrame{{ID: 0, Depth: 1}}, + nextID: 1, + path: []uint32{0}, + } +} + +// ProcessDepthChange processes a depth change and returns the current frame ID and path. +// Call this for each opcode with the opcode's depth value. +func (ct *CallTracker) ProcessDepthChange(newDepth uint64) (frameID uint32, framePath []uint32) { + currentDepth := ct.stack[len(ct.stack)-1].Depth + + if newDepth > currentDepth { + // Entering new call frame + newFrame := CallFrame{ID: ct.nextID, Depth: newDepth} + ct.stack = append(ct.stack, newFrame) + ct.path = append(ct.path, ct.nextID) + ct.nextID++ + } else if newDepth < currentDepth { + // Returning from call(s) - pop frames until depth matches + for len(ct.stack) > 1 && ct.stack[len(ct.stack)-1].Depth > newDepth { + ct.stack = ct.stack[:len(ct.stack)-1] + ct.path = ct.path[:len(ct.path)-1] + } + } + + // Return current frame info (copy path to avoid mutation issues) + pathCopy := make([]uint32, len(ct.path)) + copy(pathCopy, ct.path) + + return ct.stack[len(ct.stack)-1].ID, pathCopy +} + +// CurrentFrameID returns the current frame ID without processing a depth change. +func (ct *CallTracker) CurrentFrameID() uint32 { + return ct.stack[len(ct.stack)-1].ID +} + +// CurrentPath returns a copy of the current path. +func (ct *CallTracker) CurrentPath() []uint32 { + pathCopy := make([]uint32, len(ct.path)) + copy(pathCopy, ct.path) + + return pathCopy +} diff --git a/pkg/processor/transaction/structlog/call_tracker_test.go b/pkg/processor/transaction/structlog/call_tracker_test.go new file mode 100644 index 0000000..810e3bf --- /dev/null +++ b/pkg/processor/transaction/structlog/call_tracker_test.go @@ -0,0 +1,238 @@ +package structlog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCallTracker(t *testing.T) { + ct := NewCallTracker() + + assert.Equal(t, uint32(0), ct.CurrentFrameID()) + assert.Equal(t, []uint32{0}, ct.CurrentPath()) +} + +func TestCallTracker_SameDepth(t *testing.T) { + ct := NewCallTracker() + + // All opcodes at depth 1 should stay in frame 0 (root) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) +} + +func TestCallTracker_SingleCall(t *testing.T) { + ct := NewCallTracker() + + // depth=1: root frame (EVM traces start at depth 1) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // depth=2: entering first call + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=2: still in first call + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=1: returned from call + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) +} + +func TestCallTracker_NestedCalls(t *testing.T) { + ct := NewCallTracker() + + // depth=1: root (EVM traces start at depth 1) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // depth=2: first call + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=3: nested call + frameID, path = ct.ProcessDepthChange(3) + assert.Equal(t, uint32(2), frameID) + assert.Equal(t, []uint32{0, 1, 2}, path) + + // depth=4: deeper nested call + frameID, path = ct.ProcessDepthChange(4) + assert.Equal(t, uint32(3), frameID) + assert.Equal(t, []uint32{0, 1, 2, 3}, path) + + // depth=3: return from depth 4 + frameID, path = ct.ProcessDepthChange(3) + assert.Equal(t, uint32(2), frameID) + assert.Equal(t, []uint32{0, 1, 2}, path) + + // depth=2: return from depth 3 + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=1: return to root + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) +} + +func TestCallTracker_SiblingCalls(t *testing.T) { + // Tests the scenario from the plan: + // root -> CALL (0x123) -> CALL (0x456) -> CALL (0x789) + // root -> CALL (0xabc) -> CALL (0x456) -> CALL (0x789) + ct := NewCallTracker() + + // depth=1: root (EVM traces start at depth 1) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // First branch: depth=2 (call to 0x123) + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=3 (call to 0x456) + frameID, path = ct.ProcessDepthChange(3) + assert.Equal(t, uint32(2), frameID) + assert.Equal(t, []uint32{0, 1, 2}, path) + + // depth=4 (call to 0x789) + frameID, path = ct.ProcessDepthChange(4) + assert.Equal(t, uint32(3), frameID) + assert.Equal(t, []uint32{0, 1, 2, 3}, path) + + // Return all the way to root + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // Second branch: depth=2 (call to 0xabc) - NEW frame_id! + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(4), frameID, "sibling call should get new frame_id") + assert.Equal(t, []uint32{0, 4}, path) + + // depth=3 (call to 0x456 again) - NEW frame_id! + frameID, path = ct.ProcessDepthChange(3) + assert.Equal(t, uint32(5), frameID, "same contract different call should get new frame_id") + assert.Equal(t, []uint32{0, 4, 5}, path) + + // depth=4 (call to 0x789 again) - NEW frame_id! + frameID, path = ct.ProcessDepthChange(4) + assert.Equal(t, uint32(6), frameID, "same contract different call should get new frame_id") + assert.Equal(t, []uint32{0, 4, 5, 6}, path) +} + +func TestCallTracker_MultipleReturns(t *testing.T) { + // Test returning multiple levels at once (e.g., REVERT that unwinds multiple frames) + ct := NewCallTracker() + + // Build up: depth 1 -> 2 -> 3 -> 4 (EVM traces start at depth 1) + ct.ProcessDepthChange(1) + ct.ProcessDepthChange(2) + ct.ProcessDepthChange(3) + frameID, path := ct.ProcessDepthChange(4) + assert.Equal(t, uint32(3), frameID) + assert.Equal(t, []uint32{0, 1, 2, 3}, path) + + // Jump directly from depth 4 to depth 2 (skipping depth 3) + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) +} + +func TestCallTracker_PathIsCopy(t *testing.T) { + ct := NewCallTracker() + + ct.ProcessDepthChange(1) + _, path1 := ct.ProcessDepthChange(2) + + // Modify path1, should not affect tracker's internal state + path1[0] = 999 + + _, path2 := ct.ProcessDepthChange(2) + require.Len(t, path2, 2) + assert.Equal(t, uint32(0), path2[0], "modifying returned path should not affect tracker") +} + +func TestCallTracker_DepthStartsAtOne(t *testing.T) { + // EVM traces always start at depth 1, which is the root frame (ID 0) + ct := NewCallTracker() + + // First opcode at depth 1 - should be frame 0 (root) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // Stay at depth 1 + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // Go deeper - creates frame 1 + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) +} + +func TestCallTracker_RealWorldExample(t *testing.T) { + // Simulate a real EVM trace where depth starts at 1: + // op=PUSH1, depth=1 → frame_id=0, path=[0] (root execution) + // op=CALL(A),depth=1 → frame_id=0, path=[0] + // op=ADD, depth=2 → frame_id=1, path=[0,1] (inside A) + // op=CALL(B),d=2 → frame_id=1, path=[0,1] + // op=MUL, d=3 → frame_id=2, path=[0,1,2] (inside B) + // op=CALL(C),d=3 → frame_id=2, path=[0,1,2] + // op=SLOAD,d=4 → frame_id=3, path=[0,1,2,3] (inside C) + // op=RETURN,d=4 → frame_id=3, path=[0,1,2,3] + // op=ADD, d=3 → frame_id=2, path=[0,1,2] (back in B) + // op=RETURN,d=3 → frame_id=2, path=[0,1,2] + // op=POP, depth=2 → frame_id=1, path=[0,1] (back in A) + // op=STOP, depth=1 → frame_id=0, path=[0] (back in root) + ct := NewCallTracker() + + type expected struct { + depth uint64 + frameID uint32 + path []uint32 + } + + testCases := []expected{ + {1, 0, []uint32{0}}, // PUSH1 (root) + {1, 0, []uint32{0}}, // CALL(A) + {2, 1, []uint32{0, 1}}, // ADD (inside A) + {2, 1, []uint32{0, 1}}, // CALL(B) + {3, 2, []uint32{0, 1, 2}}, // MUL (inside B) + {3, 2, []uint32{0, 1, 2}}, // CALL(C) + {4, 3, []uint32{0, 1, 2, 3}}, // SLOAD (inside C) + {4, 3, []uint32{0, 1, 2, 3}}, // RETURN (inside C) + {3, 2, []uint32{0, 1, 2}}, // ADD (back in B) + {3, 2, []uint32{0, 1, 2}}, // RETURN (inside B) + {2, 1, []uint32{0, 1}}, // POP (back in A) + {1, 0, []uint32{0}}, // STOP (back in root) + } + + for i, tc := range testCases { + frameID, path := ct.ProcessDepthChange(tc.depth) + assert.Equal(t, tc.frameID, frameID, "case %d: frame_id mismatch", i) + assert.Equal(t, tc.path, path, "case %d: path mismatch", i) + } +} diff --git a/pkg/processor/transaction/structlog/create_address_test.go b/pkg/processor/transaction/structlog/create_address_test.go new file mode 100644 index 0000000..b77f466 --- /dev/null +++ b/pkg/processor/transaction/structlog/create_address_test.go @@ -0,0 +1,262 @@ +package structlog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" +) + +const testCreateAddress = "0x1234567890abcdef1234567890abcdef12345678" + +func TestComputeCreateAddresses_Empty(t *testing.T) { + result := ComputeCreateAddresses([]execution.StructLog{}) + assert.Empty(t, result) +} + +func TestComputeCreateAddresses_NoCREATE(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1}, + {Op: "CALL", Depth: 1}, + {Op: "ADD", Depth: 2}, + {Op: "RETURN", Depth: 2}, + {Op: "STOP", Depth: 1}, + } + + result := ComputeCreateAddresses(structlogs) + assert.Empty(t, result) +} + +func TestComputeCreateAddresses_SingleCREATE(t *testing.T) { + // Simulate: CREATE at depth 2, constructor runs at depth 3, returns + createdAddr := "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + stack := []string{createdAddr} + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 2}, + {Op: "CREATE", Depth: 2}, // index 1 + {Op: "PUSH1", Depth: 3}, // constructor starts + {Op: "RETURN", Depth: 3}, // constructor ends + {Op: "SWAP1", Depth: 2, Stack: &stack}, // back in caller, stack has address + } + + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + // Address is already 40 chars, so stays the same + assert.Equal(t, createdAddr, *result[1]) +} + +func TestComputeCreateAddresses_CREATE2(t *testing.T) { + createdAddr := "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + stack := []string{createdAddr} + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1}, + {Op: "CREATE2", Depth: 1}, // index 1 + {Op: "ADD", Depth: 2}, // constructor + {Op: "RETURN", Depth: 2}, // constructor ends + {Op: "POP", Depth: 1, Stack: &stack}, // back in caller + } + + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + assert.Equal(t, createdAddr, *result[1]) +} + +func TestComputeCreateAddresses_FailedCREATE(t *testing.T) { + // When CREATE fails immediately, next opcode is at same depth with 0 on stack + zeroAddr := "0x0" + stack := []string{zeroAddr} + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 2}, + {Op: "CREATE", Depth: 2}, // index 1 - fails immediately + {Op: "ISZERO", Depth: 2, Stack: &stack}, // still at depth 2, stack has 0 + } + + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + // Zero address is zero-padded to 40 hex chars + assert.Equal(t, "0x0000000000000000000000000000000000000000", *result[1]) +} + +func TestComputeCreateAddresses_NestedCREATEs(t *testing.T) { + // Outer CREATE at depth 1, inner CREATE at depth 2 + innerAddr := "0x1111111111111111111111111111111111111111" + outerAddr := "0x2222222222222222222222222222222222222222" + innerStack := []string{innerAddr} + outerStack := []string{outerAddr} + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1}, + {Op: "CREATE", Depth: 1}, // index 1 - outer CREATE + {Op: "PUSH1", Depth: 2}, // outer constructor starts + {Op: "CREATE", Depth: 2}, // index 3 - inner CREATE + {Op: "ADD", Depth: 3}, // inner constructor + {Op: "RETURN", Depth: 3}, // inner constructor ends + {Op: "POP", Depth: 2, Stack: &innerStack}, // back in outer constructor + {Op: "RETURN", Depth: 2}, // outer constructor ends + {Op: "SWAP1", Depth: 1, Stack: &outerStack}, // back in original caller + } + + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + require.Contains(t, result, 3) + assert.Equal(t, outerAddr, *result[1]) + assert.Equal(t, innerAddr, *result[3]) +} + +func TestComputeCreateAddresses_MultipleCREATEsSameDepth(t *testing.T) { + // Two CREATEs at the same depth (sequential, not nested) + addr1 := "0x1111111111111111111111111111111111111111" + addr2 := "0x2222222222222222222222222222222222222222" + stack1 := []string{addr1} + stack2 := []string{addr2} + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1}, + {Op: "CREATE", Depth: 1}, // index 1 - first CREATE + {Op: "ADD", Depth: 2}, // first constructor + {Op: "RETURN", Depth: 2}, // first constructor ends + {Op: "POP", Depth: 1, Stack: &stack1}, // back, has first address + {Op: "PUSH1", Depth: 1}, + {Op: "CREATE", Depth: 1}, // index 6 - second CREATE + {Op: "MUL", Depth: 2}, // second constructor + {Op: "RETURN", Depth: 2}, // second constructor ends + {Op: "SWAP1", Depth: 1, Stack: &stack2}, // back, has second address + } + + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + require.Contains(t, result, 6) + assert.Equal(t, addr1, *result[1]) + assert.Equal(t, addr2, *result[6]) +} + +func TestExtractCallAddressWithCreate_CREATE(t *testing.T) { + p := &Processor{} + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE", + }, 0, createAddresses) + + assert.NotNil(t, result) + assert.Equal(t, testCreateAddress, *result) +} + +func TestExtractCallAddressWithCreate_CREATE2(t *testing.T) { + p := &Processor{} + addr := "0xabcdef1234567890abcdef1234567890abcdef12" + createAddresses := map[int]*string{ + 5: ptrString(addr), + } + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE2", + }, 5, createAddresses) + + assert.NotNil(t, result) + assert.Equal(t, addr, *result) +} + +func TestExtractCallAddressWithCreate_CREATEWithNilMap(t *testing.T) { + p := &Processor{} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE", + }, 0, nil) + + assert.Nil(t, result) +} + +func TestExtractCallAddressWithCreate_CREATENotInMap(t *testing.T) { + p := &Processor{} + createAddresses := map[int]*string{ + 10: ptrString(testCreateAddress), + } + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE", + }, 5, createAddresses) // index 5 not in map + + assert.Nil(t, result) +} + +func TestExtractCallAddressWithCreate_CALLDelegatesToExtractCallAddress(t *testing.T) { + p := &Processor{} + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } + stack := []string{"0x5208", "0xdeadbeef"} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }, 0, createAddresses) + + // Should use extractCallAddress, not createAddresses + assert.NotNil(t, result) + // Second from top of stack, zero-padded to 40 hex chars + assert.Equal(t, "0x0000000000000000000000000000000000005208", *result) +} + +func TestExtractCallAddressWithCreate_DELEGATECALLDelegatesToExtractCallAddress(t *testing.T) { + p := &Processor{} + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } + stack := []string{"0x5208", "0xdeadbeef"} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "DELEGATECALL", + Stack: &stack, + }, 0, createAddresses) + + assert.NotNil(t, result) + // Zero-padded to 40 hex chars + assert.Equal(t, "0x0000000000000000000000000000000000005208", *result) +} + +func TestExtractCallAddressWithCreate_NonCallOpcodeReturnsNil(t *testing.T) { + p := &Processor{} + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } + stack := []string{"0x5208", "0xdeadbeef"} + + testCases := []string{ + "PUSH1", + "ADD", + "SLOAD", + "SSTORE", + "RETURN", + "REVERT", + "STOP", + } + + for _, op := range testCases { + t.Run(op, func(t *testing.T) { + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: op, + Stack: &stack, + }, 0, createAddresses) + + assert.Nil(t, result, "opcode %s should return nil", op) + }) + } +} + +// ptrString returns a pointer to the given string. +func ptrString(s string) *string { + return &s +} diff --git a/pkg/processor/transaction/structlog/extract_call_address_test.go b/pkg/processor/transaction/structlog/extract_call_address_test.go new file mode 100644 index 0000000..d72cb7f --- /dev/null +++ b/pkg/processor/transaction/structlog/extract_call_address_test.go @@ -0,0 +1,270 @@ +package structlog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" +) + +func TestExtractCallAddress_NilStack(t *testing.T) { + p := &Processor{} + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: nil, + }) + + assert.Nil(t, result) +} + +func TestExtractCallAddress_EmptyStack(t *testing.T) { + p := &Processor{} + emptyStack := []string{} + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &emptyStack, + }) + + assert.Nil(t, result) +} + +func TestExtractCallAddress_InsufficientStack(t *testing.T) { + p := &Processor{} + stack := []string{"0x1234"} // Only 1 element, need at least 2 + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.Nil(t, result) +} + +func TestExtractCallAddress_CALL(t *testing.T) { + p := &Processor{} + // CALL stack (index 0 = bottom, len-1 = top): + // [retSize, retOffset, argsSize, argsOffset, value, addr, gas] + // Address is at index len-2 (second from top) + stack := []string{ + "0x0", // retSize (bottom, index 0) + "0x0", // retOffset + "0x0", // argsSize + "0x0", // argsOffset + "0x0", // value + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr (index len-2) + "0x5208", // gas (top, index len-1) + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_CALL_MinimalStack(t *testing.T) { + p := &Processor{} + // Minimal stack with just 2 elements (addr at index 0, gas at index 1) + stack := []string{ + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr (index 0 = len-2) + "0x5208", // gas (index 1 = len-1) + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_CALL_WithExtraStackItemsBelow(t *testing.T) { + p := &Processor{} + // Stack with extra items BELOW CALL args (at the bottom) + // The CALL args are still at the top, so len-2 still gives addr + stack := []string{ + "0xdeadbeef", // extra item (bottom) + "0xcafebabe", // another extra item + "0x0", // retSize (start of CALL args) + "0x0", // retOffset + "0x0", // argsSize + "0x0", // argsOffset + "0x0", // value + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr (len-2) + "0x5208", // gas (top, len-1) + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_CALLCODE(t *testing.T) { + p := &Processor{} + // CALLCODE has same stack layout as CALL + stack := []string{ + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + "0x5208", // gas + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALLCODE", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_DELEGATECALL(t *testing.T) { + p := &Processor{} + // DELEGATECALL stack (no value parameter, but addr still at len-2): + // [retSize, retOffset, argsSize, argsOffset, addr, gas] + stack := []string{ + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + "0x5208", // gas + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "DELEGATECALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_STATICCALL(t *testing.T) { + p := &Processor{} + // STATICCALL has same stack layout as DELEGATECALL + stack := []string{ + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + "0x5208", // gas + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "STATICCALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_NonCallOpcode(t *testing.T) { + p := &Processor{} + stack := []string{"0x1234", "0x5678"} + + testCases := []string{ + "PUSH1", + "ADD", + "SLOAD", + "SSTORE", + "JUMP", + "RETURN", + "REVERT", + "CREATE", // CREATE is not handled (address comes from trace) + "CREATE2", // CREATE2 is not handled (address comes from trace) + } + + for _, op := range testCases { + t.Run(op, func(t *testing.T) { + result := p.extractCallAddress(&execution.StructLog{ + Op: op, + Stack: &stack, + }) + assert.Nil(t, result, "opcode %s should not extract call address", op) + }) + } +} + +func TestExtractCallAddress_ShortAddressPadding(t *testing.T) { + p := &Processor{} + // Test that short addresses (like precompiles) get zero-padded + stack := []string{ + "0x1", // addr - precompile ecRecover, should be padded + "0x5208", // gas + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x0000000000000000000000000000000000000001", *result) + assert.Len(t, *result, 42) +} + +func TestExtractCallAddress_Permit2Padding(t *testing.T) { + p := &Processor{} + // Test Permit2 address with leading zeros + stack := []string{ + "0x22d473030f116ddee9f6b43ac78ba3", // Permit2 truncated + "0x5208", // gas + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x000000000022d473030f116ddee9f6b43ac78ba3", *result) + assert.Len(t, *result, 42) +} + +func TestExtractCallAddress_AllCallVariants(t *testing.T) { + // Table-driven test for all supported CALL variants + p := &Processor{} + + targetAddr := "0x7a250d5630b4cf539739df2c5dacb4c659f2488d" + + testCases := []struct { + name string + op string + stack []string // Stack with addr at len-2 and gas at len-1 + }{ + { + name: "CALL with full stack", + op: "CALL", + stack: []string{"0xretSize", "0xretOff", "0xargsSize", "0xargsOff", "0xvalue", targetAddr, "0xgas"}, + }, + { + name: "CALLCODE with full stack", + op: "CALLCODE", + stack: []string{"0xretSize", "0xretOff", "0xargsSize", "0xargsOff", "0xvalue", targetAddr, "0xgas"}, + }, + { + name: "DELEGATECALL with full stack", + op: "DELEGATECALL", + stack: []string{"0xretSize", "0xretOff", "0xargsSize", "0xargsOff", targetAddr, "0xgas"}, + }, + { + name: "STATICCALL with full stack", + op: "STATICCALL", + stack: []string{"0xretSize", "0xretOff", "0xargsSize", "0xargsOff", targetAddr, "0xgas"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := p.extractCallAddress(&execution.StructLog{ + Op: tc.op, + Stack: &tc.stack, + }) + assert.NotNil(t, result) + assert.Equal(t, targetAddr, *result) + }) + } +} diff --git a/pkg/processor/transaction/structlog/format_address_test.go b/pkg/processor/transaction/structlog/format_address_test.go new file mode 100644 index 0000000..7b26b62 --- /dev/null +++ b/pkg/processor/transaction/structlog/format_address_test.go @@ -0,0 +1,115 @@ +package structlog + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatAddress(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "already 40 chars with 0x prefix", + input: "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + expected: "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + }, + { + name: "already 40 chars without 0x prefix", + input: "7a250d5630b4cf539739df2c5dacb4c659f2488d", + expected: "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + }, + { + name: "precompile address 0x1", + input: "0x1", + expected: "0x0000000000000000000000000000000000000001", + }, + { + name: "precompile address 0xa", + input: "0xa", + expected: "0x000000000000000000000000000000000000000a", + }, + { + name: "Permit2 with leading zeros truncated", + input: "0x22d473030f116ddee9f6b43ac78ba3", + expected: "0x000000000022d473030f116ddee9f6b43ac78ba3", + }, + { + name: "Uniswap PoolManager with leading zeros truncated", + input: "0x4444c5dc75cb358380d2e3de08a90", + expected: "0x000000000004444c5dc75cb358380d2e3de08a90", + }, + { + name: "zero address", + input: "0x0", + expected: "0x0000000000000000000000000000000000000000", + }, + { + name: "short address without 0x prefix", + input: "5208", + expected: "0x0000000000000000000000000000000000005208", + }, + { + name: "short address with 0x prefix", + input: "0x5208", + expected: "0x0000000000000000000000000000000000005208", + }, + { + name: "empty string", + input: "", + expected: "0x0000000000000000000000000000000000000000", + }, + { + name: "just 0x prefix", + input: "0x", + expected: "0x0000000000000000000000000000000000000000", + }, + // Full 32-byte stack values (66 chars) - extract lower 20 bytes + { + name: "full 32-byte stack value from XEN Batch Minter", + input: "0x661f30bf3a790c8687131ae8fc6e649df9f27275fc286db8f1a0be7e99b24bb2", + expected: "0xfc6e649df9f27275fc286db8f1a0be7e99b24bb2", + }, + { + name: "full 32-byte stack value - all zeros except address", + input: "0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d", + expected: "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + }, + { + name: "full 32-byte stack value without 0x prefix", + input: "661f30bf3a790c8687131ae8fc6e649df9f27275fc286db8f1a0be7e99b24bb2", + expected: "0xfc6e649df9f27275fc286db8f1a0be7e99b24bb2", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := formatAddress(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestFormatAddress_LengthConsistency(t *testing.T) { + // All formatted addresses should be exactly 42 characters (0x + 40 hex chars) + inputs := []string{ + "0x1", + "0xa", + "0xdeadbeef", + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + "1", + "abcdef", + "", + } + + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + result := formatAddress(input) + assert.Len(t, result, 42, "formatted address should always be 42 chars") + assert.Equal(t, "0x", result[:2], "formatted address should start with 0x") + }) + } +} diff --git a/pkg/processor/transaction/structlog/gas_cost.go b/pkg/processor/transaction/structlog/gas_cost.go index aa5c0fd..d32572e 100644 --- a/pkg/processor/transaction/structlog/gas_cost.go +++ b/pkg/processor/transaction/structlog/gas_cost.go @@ -6,6 +6,49 @@ import ( "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" ) +// ============================================================================= +// GAS FIELDS +// ============================================================================= +// +// The structlog contains three gas-related fields: +// +// GasCost +// Source: Directly from geth/erigon debug_traceTransaction response. +// For non-CALL opcodes: The static cost charged for the opcode. +// For CALL/CREATE opcodes: The gas stipend passed to the child frame. +// +// GasUsed +// Source: Computed as gas[i] - gas[i+1] for consecutive opcodes at same depth. +// For non-CALL opcodes: Actual gas consumed by the opcode. +// For CALL/CREATE opcodes: Includes the call overhead PLUS all child frame gas. +// Note: Summing gas_used across all opcodes double counts because CALL's +// gas_used includes child gas, and children also report their own gas_used. +// +// GasSelf +// Source: Computed as gas_used minus the sum of all child frame gas_used. +// For non-CALL opcodes: Equal to gas_used. +// For CALL/CREATE opcodes: Only the call overhead (warm/cold access, memory +// expansion, value transfer) without child frame gas. +// Summing gas_self across all opcodes gives total execution gas without +// double counting. +// +// Example for a CALL opcode: +// gas_cost = 7,351,321 (stipend passed to child) +// gas_used = 23,858 (overhead 2,600 + child consumed 21,258) +// gas_self = 2,600 (just the CALL overhead) +// +// ============================================================================= + +// Opcode constants for call and create operations. +const ( + OpcodeCALL = "CALL" + OpcodeCALLCODE = "CALLCODE" + OpcodeDELEGATECALL = "DELEGATECALL" + OpcodeSTATICCALL = "STATICCALL" + OpcodeCREATE = "CREATE" + OpcodeCREATE2 = "CREATE2" +) + // ComputeGasUsed calculates the actual gas consumed for each structlog using // the difference between consecutive gas values at the same depth level. // @@ -62,3 +105,65 @@ func ComputeGasUsed(structlogs []execution.StructLog) []uint64 { return gasUsed } + +// ComputeGasSelf calculates the gas consumed by each opcode excluding child frame gas. +// For CALL/CREATE opcodes, this represents only the call overhead (warm/cold access, +// memory expansion, value transfer), not the gas consumed by child frames. +// For all other opcodes, this equals gasUsed. +// +// This is useful for gas analysis where you want to sum gas without double counting: +// sum(gasSelf) = total transaction execution gas (no double counting). +func ComputeGasSelf(structlogs []execution.StructLog, gasUsed []uint64) []uint64 { + if len(structlogs) == 0 { + return nil + } + + gasSelf := make([]uint64, len(structlogs)) + copy(gasSelf, gasUsed) + + for i := range structlogs { + op := structlogs[i].Op + if !isCallOrCreateOpcode(op) { + continue + } + + callDepth := structlogs[i].Depth + + var childGasSum uint64 + + // Sum gas_used for DIRECT children only (depth == callDepth + 1). + // We only sum direct children because their gas_used already includes + // any nested descendants. Summing all descendants would double count. + for j := i + 1; j < len(structlogs); j++ { + if structlogs[j].Depth <= callDepth { + break + } + + if structlogs[j].Depth == callDepth+1 { + childGasSum += gasUsed[j] + } + } + + // gasSelf = total gas attributed to this CALL minus child execution + // This gives us just the CALL overhead + if gasUsed[i] >= childGasSum { + gasSelf[i] = gasUsed[i] - childGasSum + } else { + // Edge case: if child gas exceeds parent (shouldn't happen in valid traces) + // fall back to 0 to avoid underflow + gasSelf[i] = 0 + } + } + + return gasSelf +} + +// isCallOrCreateOpcode returns true if the opcode spawns a new call frame. +func isCallOrCreateOpcode(op string) bool { + switch op { + case OpcodeCALL, OpcodeCALLCODE, OpcodeDELEGATECALL, OpcodeSTATICCALL, OpcodeCREATE, OpcodeCREATE2: + return true + default: + return false + } +} diff --git a/pkg/processor/transaction/structlog/gas_cost_test.go b/pkg/processor/transaction/structlog/gas_cost_test.go index 868d690..cf85c08 100644 --- a/pkg/processor/transaction/structlog/gas_cost_test.go +++ b/pkg/processor/transaction/structlog/gas_cost_test.go @@ -313,3 +313,286 @@ func TestComputeGasUsed_LargeDepth(t *testing.T) { assert.Equal(t, uint64(2), result[8]) assert.Equal(t, uint64(2), result[9]) } + +// ============================================================================= +// ComputeGasSelf Tests +// ============================================================================= + +func TestComputeGasSelf_EmptyLogs(t *testing.T) { + result := ComputeGasSelf(nil, nil) + assert.Nil(t, result) + + result = ComputeGasSelf([]execution.StructLog{}, []uint64{}) + assert.Nil(t, result) +} + +func TestComputeGasSelf_NonCallOpcodes(t *testing.T) { + // For non-CALL opcodes, gas_self should equal gas_used + structlogs := []execution.StructLog{ + {Op: "PUSH1", Gas: 100000, GasCost: 3, Depth: 1}, + {Op: "SLOAD", Gas: 99997, GasCost: 2100, Depth: 1}, + {Op: "ADD", Gas: 97897, GasCost: 3, Depth: 1}, + } + + gasUsed := []uint64{3, 2100, 3} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 3) + assert.Equal(t, uint64(3), result[0], "PUSH1 gas_self should equal gas_used") + assert.Equal(t, uint64(2100), result[1], "SLOAD gas_self should equal gas_used") + assert.Equal(t, uint64(3), result[2], "ADD gas_self should equal gas_used") +} + +func TestComputeGasSelf_SimpleCall(t *testing.T) { + // CALL at depth 1 with child opcodes at depth 2 + // gas_self for CALL should be gas_used minus sum of direct children's gas_used + structlogs := []execution.StructLog{ + {Op: "PUSH1", Gas: 100000, GasCost: 3, Depth: 1}, // index 0 + {Op: "CALL", Gas: 99997, GasCost: 100, Depth: 1}, // index 1: CALL + {Op: "PUSH1", Gas: 63000, GasCost: 3, Depth: 2}, // index 2: child + {Op: "ADD", Gas: 62000, GasCost: 3, Depth: 2}, // index 3: child + {Op: "STOP", Gas: 61000, GasCost: 0, Depth: 2}, // index 4: child + {Op: "POP", Gas: 97000, GasCost: 2, Depth: 1}, // index 5: back to parent + } + + // gas_used values (computed by ComputeGasUsed logic): + // PUSH1[0]: 100000 - 99997 = 3 + // CALL[1]: 99997 - 97000 = 2997 (includes child execution) + // PUSH1[2]: 63000 - 62000 = 1000 + // ADD[3]: 62000 - 61000 = 1000 + // STOP[4]: 0 (pre-calculated, last at depth 2) + // POP[5]: 2 (pre-calculated, last opcode) + gasUsed := []uint64{3, 2997, 1000, 1000, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 6) + + // Non-CALL opcodes: gas_self == gas_used + assert.Equal(t, uint64(3), result[0], "PUSH1 gas_self") + assert.Equal(t, uint64(1000), result[2], "child PUSH1 gas_self") + assert.Equal(t, uint64(1000), result[3], "child ADD gas_self") + assert.Equal(t, uint64(0), result[4], "child STOP gas_self") + assert.Equal(t, uint64(2), result[5], "POP gas_self") + + // CALL: gas_self = gas_used - sum(direct children) + // direct children at depth 2: indices 2, 3, 4 + // sum = 1000 + 1000 + 0 = 2000 + // gas_self = 2997 - 2000 = 997 + assert.Equal(t, uint64(997), result[1], "CALL gas_self should be overhead only") +} + +func TestComputeGasSelf_NestedCalls(t *testing.T) { + // This is the critical test: nested CALLs where we must only sum direct children. + // If we sum ALL descendants, we double count and get incorrect (often 0) values. + // + // Structure: + // CALL A (depth 1) -> child frame at depth 2 + // ├─ PUSH (depth 2) + // ├─ CALL B (depth 2) -> grandchild frame at depth 3 + // │ ├─ ADD (depth 3) + // │ └─ STOP (depth 3) + // └─ STOP (depth 2) + structlogs := []execution.StructLog{ + {Op: "CALL", Gas: 100000, GasCost: 100, Depth: 1}, // index 0: CALL A + {Op: "PUSH1", Gas: 80000, GasCost: 3, Depth: 2}, // index 1: direct child of A + {Op: "CALL", Gas: 79000, GasCost: 100, Depth: 2}, // index 2: CALL B (direct child of A) + {Op: "ADD", Gas: 50000, GasCost: 3, Depth: 3}, // index 3: direct child of B + {Op: "STOP", Gas: 49000, GasCost: 0, Depth: 3}, // index 4: direct child of B + {Op: "STOP", Gas: 75000, GasCost: 0, Depth: 2}, // index 5: direct child of A + {Op: "POP", Gas: 90000, GasCost: 2, Depth: 1}, // index 6: back to depth 1 + } + + // gas_used values: + // CALL A[0]: 100000 - 90000 = 10000 (includes all nested) + // PUSH[1]: 80000 - 79000 = 1000 + // CALL B[2]: 79000 - 75000 = 4000 (includes grandchild) + // ADD[3]: 50000 - 49000 = 1000 + // STOP[4]: 0 (pre-calculated) + // STOP[5]: 0 (pre-calculated) + // POP[6]: 2 (pre-calculated) + gasUsed := []uint64{10000, 1000, 4000, 1000, 0, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 7) + + // CALL A: direct children at depth 2 are indices 1, 2, 5 + // sum of direct children = 1000 + 4000 + 0 = 5000 + // gas_self = 10000 - 5000 = 5000 + // Note: We do NOT include indices 3, 4 (depth 3) because they're grandchildren, + // and CALL B's gas_used (4000) already includes them. + assert.Equal(t, uint64(5000), result[0], "CALL A gas_self should exclude nested CALL's children") + + // CALL B: direct children at depth 3 are indices 3, 4 + // sum of direct children = 1000 + 0 = 1000 + // gas_self = 4000 - 1000 = 3000 + assert.Equal(t, uint64(3000), result[2], "CALL B gas_self should be its overhead") + + // Non-CALL opcodes: gas_self == gas_used + assert.Equal(t, uint64(1000), result[1], "PUSH gas_self") + assert.Equal(t, uint64(1000), result[3], "ADD gas_self") + assert.Equal(t, uint64(0), result[4], "STOP depth 3 gas_self") + assert.Equal(t, uint64(0), result[5], "STOP depth 2 gas_self") + assert.Equal(t, uint64(2), result[6], "POP gas_self") +} + +func TestComputeGasSelf_SiblingCalls(t *testing.T) { + // Two sibling CALLs at the same depth, each with their own children + structlogs := []execution.StructLog{ + {Op: "CALL", Gas: 100000, GasCost: 100, Depth: 1}, // index 0: first CALL + {Op: "ADD", Gas: 60000, GasCost: 3, Depth: 2}, // index 1: child of first CALL + {Op: "STOP", Gas: 59000, GasCost: 0, Depth: 2}, // index 2: child of first CALL + {Op: "CALL", Gas: 90000, GasCost: 100, Depth: 1}, // index 3: second CALL + {Op: "MUL", Gas: 50000, GasCost: 5, Depth: 2}, // index 4: child of second CALL + {Op: "STOP", Gas: 49000, GasCost: 0, Depth: 2}, // index 5: child of second CALL + {Op: "POP", Gas: 80000, GasCost: 2, Depth: 1}, // index 6 + } + + // gas_used: + // CALL[0]: 100000 - 90000 = 10000 + // ADD[1]: 60000 - 59000 = 1000 + // STOP[2]: 0 + // CALL[3]: 90000 - 80000 = 10000 + // MUL[4]: 50000 - 49000 = 1000 + // STOP[5]: 0 + // POP[6]: 2 + gasUsed := []uint64{10000, 1000, 0, 10000, 1000, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 7) + + // First CALL: direct children = indices 1, 2 + // gas_self = 10000 - (1000 + 0) = 9000 + assert.Equal(t, uint64(9000), result[0], "first CALL gas_self") + + // Second CALL: direct children = indices 4, 5 + // gas_self = 10000 - (1000 + 0) = 9000 + assert.Equal(t, uint64(9000), result[3], "second CALL gas_self") +} + +func TestComputeGasSelf_CreateOpcode(t *testing.T) { + // CREATE should be handled the same as CALL + structlogs := []execution.StructLog{ + {Op: "CREATE", Gas: 100000, GasCost: 32000, Depth: 1}, // index 0 + {Op: "PUSH1", Gas: 70000, GasCost: 3, Depth: 2}, // index 1: constructor + {Op: "RETURN", Gas: 69000, GasCost: 0, Depth: 2}, // index 2: constructor + {Op: "POP", Gas: 80000, GasCost: 2, Depth: 1}, // index 3 + } + + // gas_used: + // CREATE[0]: 100000 - 80000 = 20000 + // PUSH[1]: 70000 - 69000 = 1000 + // RETURN[2]: 0 + // POP[3]: 2 + gasUsed := []uint64{20000, 1000, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 4) + + // CREATE: direct children = indices 1, 2 + // gas_self = 20000 - (1000 + 0) = 19000 + assert.Equal(t, uint64(19000), result[0], "CREATE gas_self should be overhead only") + assert.Equal(t, uint64(1000), result[1], "PUSH gas_self") + assert.Equal(t, uint64(0), result[2], "RETURN gas_self") + assert.Equal(t, uint64(2), result[3], "POP gas_self") +} + +func TestComputeGasSelf_DelegateCallAndStaticCall(t *testing.T) { + // DELEGATECALL and STATICCALL should also be handled + structlogs := []execution.StructLog{ + {Op: "DELEGATECALL", Gas: 100000, GasCost: 100, Depth: 1}, + {Op: "ADD", Gas: 60000, GasCost: 3, Depth: 2}, + {Op: "STOP", Gas: 59000, GasCost: 0, Depth: 2}, + {Op: "STATICCALL", Gas: 90000, GasCost: 100, Depth: 1}, + {Op: "MUL", Gas: 50000, GasCost: 5, Depth: 2}, + {Op: "STOP", Gas: 49000, GasCost: 0, Depth: 2}, + {Op: "POP", Gas: 80000, GasCost: 2, Depth: 1}, + } + + gasUsed := []uint64{10000, 1000, 0, 10000, 1000, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 7) + + // DELEGATECALL: gas_self = 10000 - 1000 = 9000 + assert.Equal(t, uint64(9000), result[0], "DELEGATECALL gas_self") + + // STATICCALL: gas_self = 10000 - 1000 = 9000 + assert.Equal(t, uint64(9000), result[3], "STATICCALL gas_self") +} + +func TestComputeGasSelf_CallWithNoChildren(t *testing.T) { + // CALL to precompile or empty contract - no child opcodes + // In this case, gas_self should equal gas_used + structlogs := []execution.StructLog{ + {Op: "CALL", Gas: 100000, GasCost: 100, Depth: 1}, + {Op: "POP", Gas: 97400, GasCost: 2, Depth: 1}, // immediately back at depth 1 + } + + // gas_used: + // CALL: 100000 - 97400 = 2600 (just the CALL overhead, no child execution) + // POP: 2 + gasUsed := []uint64{2600, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 2) + + // No children, so gas_self = gas_used + assert.Equal(t, uint64(2600), result[0], "CALL with no children: gas_self == gas_used") + assert.Equal(t, uint64(2), result[1], "POP gas_self") +} + +func TestComputeGasSelf_DeeplyNestedCalls(t *testing.T) { + // Test 4 levels of nesting to ensure correct handling + structlogs := []execution.StructLog{ + {Op: "CALL", Gas: 100000, GasCost: 100, Depth: 1}, // index 0: A + {Op: "CALL", Gas: 90000, GasCost: 100, Depth: 2}, // index 1: B + {Op: "CALL", Gas: 80000, GasCost: 100, Depth: 3}, // index 2: C + {Op: "CALL", Gas: 70000, GasCost: 100, Depth: 4}, // index 3: D + {Op: "ADD", Gas: 60000, GasCost: 3, Depth: 5}, // index 4: innermost + {Op: "STOP", Gas: 59000, GasCost: 0, Depth: 5}, // index 5 + {Op: "STOP", Gas: 65000, GasCost: 0, Depth: 4}, // index 6 + {Op: "STOP", Gas: 74000, GasCost: 0, Depth: 3}, // index 7 + {Op: "STOP", Gas: 83000, GasCost: 0, Depth: 2}, // index 8 + {Op: "POP", Gas: 92000, GasCost: 2, Depth: 1}, // index 9 + } + + // gas_used: + // A[0]: 100000 - 92000 = 8000 + // B[1]: 90000 - 83000 = 7000 + // C[2]: 80000 - 74000 = 6000 + // D[3]: 70000 - 65000 = 5000 + // ADD[4]: 60000 - 59000 = 1000 + // STOP[5]: 0 + // STOP[6]: 0 + // STOP[7]: 0 + // STOP[8]: 0 + // POP[9]: 2 + gasUsed := []uint64{8000, 7000, 6000, 5000, 1000, 0, 0, 0, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 10) + + // CALL A: direct children at depth 2 = [B, STOP] = indices 1, 8 + // gas_self = 8000 - (7000 + 0) = 1000 + assert.Equal(t, uint64(1000), result[0], "CALL A gas_self") + + // CALL B: direct children at depth 3 = [C, STOP] = indices 2, 7 + // gas_self = 7000 - (6000 + 0) = 1000 + assert.Equal(t, uint64(1000), result[1], "CALL B gas_self") + + // CALL C: direct children at depth 4 = [D, STOP] = indices 3, 6 + // gas_self = 6000 - (5000 + 0) = 1000 + assert.Equal(t, uint64(1000), result[2], "CALL C gas_self") + + // CALL D: direct children at depth 5 = [ADD, STOP] = indices 4, 5 + // gas_self = 5000 - (1000 + 0) = 4000 + assert.Equal(t, uint64(4000), result[3], "CALL D gas_self") +} diff --git a/pkg/processor/transaction/structlog/transaction_processing.go b/pkg/processor/transaction/structlog/transaction_processing.go index b700cc6..83e3595 100644 --- a/pkg/processor/transaction/structlog/transaction_processing.go +++ b/pkg/processor/transaction/structlog/transaction_processing.go @@ -3,6 +3,7 @@ package structlog import ( "context" "fmt" + "strings" "time" "github.com/ethereum/go-ethereum/core/types" @@ -12,6 +13,9 @@ import ( "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" ) +// Structlog represents a single EVM opcode execution within a transaction trace. +// See gas_cost.go for detailed documentation on the gas fields. +// //nolint:tagliatelle // ClickHouse uses snake_case column names type Structlog struct { UpdatedDateTime ClickHouseTime `json:"updated_date_time"` @@ -24,16 +28,33 @@ type Structlog struct { Index uint32 `json:"index"` ProgramCounter uint32 `json:"program_counter"` Operation string `json:"operation"` - Gas uint64 `json:"gas"` - GasCost uint64 `json:"gas_cost"` - GasUsed uint64 `json:"gas_used"` - Depth uint64 `json:"depth"` - ReturnData *string `json:"return_data"` - Refund *uint64 `json:"refund"` - Error *string `json:"error"` - CallToAddress *string `json:"call_to_address"` - MetaNetworkID int32 `json:"meta_network_id"` - MetaNetworkName string `json:"meta_network_name"` + + // Gas is the remaining gas before this opcode executes. + Gas uint64 `json:"gas"` + + // GasCost is from the execution node trace. For CALL/CREATE opcodes, this is the + // gas stipend passed to the child frame, not the call overhead. + GasCost uint64 `json:"gas_cost"` + + // GasUsed is computed as gas[i] - gas[i+1] at the same depth level. + // For CALL/CREATE opcodes, this includes the call overhead plus all child frame gas. + // Summing across all opcodes will double count child frame gas. + GasUsed uint64 `json:"gas_used"` + + // GasSelf excludes child frame gas. For CALL/CREATE opcodes, this is just the call + // overhead (warm/cold access, memory expansion). For other opcodes, equals GasUsed. + // Summing across all opcodes gives total execution gas without double counting. + GasSelf uint64 `json:"gas_self"` + + Depth uint64 `json:"depth"` + ReturnData *string `json:"return_data"` + Refund *uint64 `json:"refund"` + Error *string `json:"error"` + CallToAddress *string `json:"call_to_address"` + CallFrameID uint32 `json:"call_frame_id"` + CallFramePath []uint32 `json:"call_frame_path"` + MetaNetworkID int32 `json:"meta_network_id"` + MetaNetworkName string `json:"meta_network_name"` } // ProcessSingleTransaction processes a single transaction and inserts its structlogs directly to ClickHouse. @@ -79,6 +100,15 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, // Compute actual gas used for each structlog gasUsed := ComputeGasUsed(trace.Structlogs) + // Compute self gas (excludes child frame gas for CALL/CREATE opcodes) + gasSelf := ComputeGasSelf(trace.Structlogs, gasUsed) + + // Initialize call frame tracker + callTracker := NewCallTracker() + + // Pre-compute CREATE/CREATE2 addresses from trace stack + createAddresses := ComputeCreateAddresses(trace.Structlogs) + // Check if this is a big transaction and register if needed if totalCount >= p.bigTxManager.GetThreshold() { p.bigTxManager.RegisterBigTransaction(tx.Hash().String(), p) @@ -137,7 +167,11 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, // Producer - convert and send batches batch := make([]Structlog, 0, chunkSize) + for i := 0; i < totalCount; i++ { + // Track call frame based on depth changes + frameID, framePath := callTracker.ProcessDepthChange(trace.Structlogs[i].Depth) + // Convert structlog batch = append(batch, Structlog{ UpdatedDateTime: NewClickHouseTime(time.Now()), @@ -153,11 +187,14 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, Gas: trace.Structlogs[i].Gas, GasCost: trace.Structlogs[i].GasCost, GasUsed: gasUsed[i], + GasSelf: gasSelf[i], Depth: trace.Structlogs[i].Depth, ReturnData: trace.Structlogs[i].ReturnData, Refund: trace.Structlogs[i].Refund, Error: trace.Structlogs[i].Error, - CallToAddress: p.extractCallAddress(&trace.Structlogs[i]), + CallToAddress: p.extractCallAddressWithCreate(&trace.Structlogs[i], i, createAddresses), + CallFrameID: frameID, + CallFramePath: framePath, MetaNetworkID: p.network.ID, MetaNetworkName: p.network.Name, }) @@ -227,15 +264,135 @@ func (p *Processor) getTransactionTrace(ctx context.Context, tx *types.Transacti return trace, nil } -// extractCallAddress extracts the call address from a structlog if it's a CALL operation. +// formatAddress normalizes an address to exactly 42 characters (0x + 40 hex). +// +// Background: The EVM is a 256-bit (32-byte) stack machine. ALL stack values are 32 bytes, +// including addresses. When execution clients like Erigon/Geth return debug traces, the +// stack array contains raw 32-byte values as hex strings (66 chars with 0x prefix). +// +// However, Ethereum addresses are only 160 bits (20 bytes, 40 hex chars). In EVM/ABI encoding, +// addresses are stored in the LOWER 160 bits of the 32-byte word (right-aligned, left-padded +// with zeros). For example, address 0x7a250d5630b4cf539739df2c5dacb4c659f2488d on the stack: +// +// 0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d +// |-------- upper 12 bytes (zeros) --------||---- lower 20 bytes (address) ----| +// +// Some contracts may have non-zero upper bytes in the stack value. The EVM ignores these +// when interpreting the value as an address - only the lower 20 bytes are used. +// +// This function handles three cases: +// 1. Short addresses (e.g., "0x1" for precompiles): left-pad with zeros to 40 hex chars +// 2. Full 32-byte stack values (66 chars): extract rightmost 40 hex chars (lower 160 bits) +// 3. Normal 42-char addresses: return as-is +func formatAddress(addr string) string { + // Remove 0x prefix if present + hex := strings.TrimPrefix(addr, "0x") + + // If longer than 40 chars, extract the lower 20 bytes (rightmost 40 hex chars). + // This handles raw 32-byte stack values from execution client traces. + if len(hex) > 40 { + hex = hex[len(hex)-40:] + } + + // Left-pad with zeros to 40 chars if shorter (handles precompiles like 0x1), + // then add 0x prefix + return fmt.Sprintf("0x%040s", hex) +} + +// extractCallAddress extracts the target address from a CALL-type opcode's stack. +// Handles CALL, CALLCODE, DELEGATECALL, and STATICCALL opcodes. +// For CREATE/CREATE2, use extractCallAddressWithCreate instead. +// +// Stack layout in Erigon/Geth debug traces: +// - Array index 0 = bottom of stack (oldest value, first pushed) +// - Array index len-1 = top of stack (newest value, first to be popped) +// +// When a CALL opcode executes, its arguments are at the top of the stack: +// +// CALL/CALLCODE: [..., retSize, retOffset, argsSize, argsOffset, value, addr, gas] +// DELEGATECALL/STATICCALL: [..., retSize, retOffset, argsSize, argsOffset, addr, gas] +// ^ ^ +// len-2 len-1 +// +// The address is always at Stack[len-2] (second from top), regardless of how many +// other values exist below the CALL arguments on the stack. +// +// Note: The stack value is a raw 32-byte word. The formatAddress function extracts +// the actual 20-byte address from the lower 160 bits. func (p *Processor) extractCallAddress(structLog *execution.StructLog) *string { - if structLog.Op == "CALL" && structLog.Stack != nil && len(*structLog.Stack) > 1 { + if structLog.Stack == nil || len(*structLog.Stack) < 2 { + return nil + } + + switch structLog.Op { + case "CALL", "CALLCODE", "DELEGATECALL", "STATICCALL": + // Extract the raw 32-byte stack value at the address position (second from top). + // formatAddress will normalize it to a proper 20-byte address. stackValue := (*structLog.Stack)[len(*structLog.Stack)-2] + addr := formatAddress(stackValue) + + return &addr + default: + return nil + } +} + +// extractCallAddressWithCreate extracts the call address, using createAddresses map for CREATE/CREATE2 opcodes. +func (p *Processor) extractCallAddressWithCreate(structLog *execution.StructLog, index int, createAddresses map[int]*string) *string { + // For CREATE/CREATE2, use the pre-computed address from the trace + if structLog.Op == "CREATE" || structLog.Op == "CREATE2" { + if createAddresses != nil { + return createAddresses[index] + } - return &stackValue + return nil } - return nil + return p.extractCallAddress(structLog) +} + +// ComputeCreateAddresses pre-computes the created contract addresses for all CREATE/CREATE2 opcodes. +// It scans the trace and extracts addresses from the stack when each CREATE's constructor returns. +// The returned map contains opcode index -> created address (only for CREATE/CREATE2 opcodes). +func ComputeCreateAddresses(structlogs []execution.StructLog) map[int]*string { + result := make(map[int]*string) + + // Track pending CREATE operations: (index, depth) + type pendingCreate struct { + index int + depth uint64 + } + + var pending []pendingCreate + + for i, log := range structlogs { + // Resolve pending CREATEs that have completed. + // A CREATE at depth D completes when we see an opcode at depth <= D + // (either immediately if CREATE failed, or after constructor returns). + for len(pending) > 0 { + last := pending[len(pending)-1] + + // If current opcode is at or below CREATE's depth and it's not the CREATE itself + if log.Depth <= last.depth && i > last.index { + // Extract address from top of stack (created address or 0 if failed) + if log.Stack != nil && len(*log.Stack) > 0 { + addr := formatAddress((*log.Stack)[len(*log.Stack)-1]) + result[last.index] = &addr + } + + pending = pending[:len(pending)-1] + } else { + break + } + } + + // Track new CREATE/CREATE2 + if log.Op == "CREATE" || log.Op == "CREATE2" { + pending = append(pending, pendingCreate{index: i, depth: log.Depth}) + } + } + + return result } // ExtractStructlogs extracts structlog data from a transaction without inserting to database. @@ -272,16 +429,21 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i // Compute actual gas used for each structlog gasUsed := ComputeGasUsed(trace.Structlogs) + // Compute self gas (excludes child frame gas for CALL/CREATE opcodes) + gasSelf := ComputeGasSelf(trace.Structlogs, gasUsed) + + // Initialize call frame tracker + callTracker := NewCallTracker() + + // Pre-compute CREATE/CREATE2 addresses from trace stack + createAddresses := ComputeCreateAddresses(trace.Structlogs) + // Pre-allocate slice for better memory efficiency structlogs = make([]Structlog, 0, len(trace.Structlogs)) for i, structLog := range trace.Structlogs { - var callToAddress *string - - if structLog.Op == "CALL" && structLog.Stack != nil && len(*structLog.Stack) > 1 { - stackValue := (*structLog.Stack)[len(*structLog.Stack)-2] - callToAddress = &stackValue - } + // Track call frame based on depth changes + frameID, framePath := callTracker.ProcessDepthChange(structLog.Depth) row := Structlog{ UpdatedDateTime: NewClickHouseTime(time.Now()), @@ -297,11 +459,14 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i Gas: structLog.Gas, GasCost: structLog.GasCost, GasUsed: gasUsed[i], + GasSelf: gasSelf[i], Depth: structLog.Depth, ReturnData: structLog.ReturnData, Refund: structLog.Refund, Error: structLog.Error, - CallToAddress: callToAddress, + CallToAddress: p.extractCallAddressWithCreate(&structLog, i, createAddresses), + CallFrameID: frameID, + CallFramePath: framePath, MetaNetworkID: p.network.ID, MetaNetworkName: p.network.Name, }