From a978c2cd2ffd8a0fb399d0c8bf869d26ba7fea41 Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Wed, 14 Jan 2026 10:15:29 +1000 Subject: [PATCH 1/8] feat(structlog): add call frame tracking to identify EVM call contexts Introduce CallTracker to assign sequential frame IDs and maintain call paths during opcode traversal. This enables accurate identification of which contract call each opcode belongs to, even when the same contract is called multiple times. Extend Structlog with CallFrameID and CallFramePath fields to persist the tracking information alongside each opcode record. Update extractCallAddress to handle all CALL-type opcodes (CALL, CALLCODE, DELEGATECALL, STATICCALL) for complete call target extraction. --- .../transaction/structlog/call_tracker.go | 64 +++++ .../structlog/call_tracker_test.go | 238 ++++++++++++++++++ .../structlog/transaction_processing.go | 45 +++- 3 files changed, 336 insertions(+), 11 deletions(-) create mode 100644 pkg/processor/transaction/structlog/call_tracker.go create mode 100644 pkg/processor/transaction/structlog/call_tracker_test.go diff --git a/pkg/processor/transaction/structlog/call_tracker.go b/pkg/processor/transaction/structlog/call_tracker.go new file mode 100644 index 0000000..18ecca3 --- /dev/null +++ b/pkg/processor/transaction/structlog/call_tracker.go @@ -0,0 +1,64 @@ +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. +func NewCallTracker() *CallTracker { + return &CallTracker{ + stack: []CallFrame{{ID: 0, Depth: 0}}, + 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..29dfb1a --- /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 0 should stay in frame 0 + frameID, path := ct.ProcessDepthChange(0) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + frameID, path = ct.ProcessDepthChange(0) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + frameID, path = ct.ProcessDepthChange(0) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) +} + +func TestCallTracker_SingleCall(t *testing.T) { + ct := NewCallTracker() + + // depth=0: root frame + frameID, path := ct.ProcessDepthChange(0) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // depth=1: entering first call + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=1: still in first call + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=0: returned from call + frameID, path = ct.ProcessDepthChange(0) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) +} + +func TestCallTracker_NestedCalls(t *testing.T) { + ct := NewCallTracker() + + // depth=0: root + frameID, path := ct.ProcessDepthChange(0) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // depth=1: first call + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=2: nested call + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(2), frameID) + assert.Equal(t, []uint32{0, 1, 2}, path) + + // depth=3: deeper nested call + frameID, path = ct.ProcessDepthChange(3) + assert.Equal(t, uint32(3), frameID) + assert.Equal(t, []uint32{0, 1, 2, 3}, path) + + // depth=2: return from depth 3 + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(2), frameID) + assert.Equal(t, []uint32{0, 1, 2}, path) + + // depth=1: return from depth 2 + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=0: return to root + frameID, path = ct.ProcessDepthChange(0) + 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=0: root + frameID, path := ct.ProcessDepthChange(0) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // First branch: depth=1 (call to 0x123) + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=2 (call to 0x456) + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(2), frameID) + assert.Equal(t, []uint32{0, 1, 2}, path) + + // depth=3 (call to 0x789) + frameID, path = ct.ProcessDepthChange(3) + 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(0) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // Second branch: depth=1 (call to 0xabc) - NEW frame_id! + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(4), frameID, "sibling call should get new frame_id") + assert.Equal(t, []uint32{0, 4}, path) + + // depth=2 (call to 0x456 again) - NEW frame_id! + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(5), frameID, "same contract different call should get new frame_id") + assert.Equal(t, []uint32{0, 4, 5}, path) + + // depth=3 (call to 0x789 again) - NEW frame_id! + frameID, path = ct.ProcessDepthChange(3) + 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 0 -> 1 -> 2 -> 3 + ct.ProcessDepthChange(0) + ct.ProcessDepthChange(1) + ct.ProcessDepthChange(2) + frameID, path := ct.ProcessDepthChange(3) + assert.Equal(t, uint32(3), frameID) + assert.Equal(t, []uint32{0, 1, 2, 3}, path) + + // Jump directly from depth 3 to depth 1 (skipping depth 2) + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) +} + +func TestCallTracker_PathIsCopy(t *testing.T) { + ct := NewCallTracker() + + ct.ProcessDepthChange(0) + _, path1 := ct.ProcessDepthChange(1) + + // Modify path1, should not affect tracker's internal state + path1[0] = 999 + + _, path2 := ct.ProcessDepthChange(1) + 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) { + // Some EVM traces start at depth 1 instead of 0 + ct := NewCallTracker() + + // First opcode at depth 1 - should create frame 1 + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // Stay at depth 1 + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // Go deeper + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(2), frameID) + assert.Equal(t, []uint32{0, 1, 2}, path) +} + +func TestCallTracker_RealWorldExample(t *testing.T) { + // Simulate the example from the HackMD doc: + // op=PUSH1, depth=0 → frame_id=0, path=[0] + // op=CALL(A),depth=0 → frame_id=0, path=[0] + // op=ADD, depth=1 → frame_id=1, path=[0,1] + // op=CALL(B),d=1 → frame_id=1, path=[0,1] + // op=MUL, d=2 → frame_id=2, path=[0,1,2] + // op=CALL(C),d=2 → frame_id=2, path=[0,1,2] + // op=SLOAD,d=3 → frame_id=3, path=[0,1,2,3] + // op=RETURN,d=3 → frame_id=3, path=[0,1,2,3] + // op=ADD, d=2 → frame_id=2, path=[0,1,2] + // op=RETURN,d=2 → frame_id=2, path=[0,1,2] + // op=POP, depth=1 → frame_id=1, path=[0,1] + // op=STOP, depth=0 → frame_id=0, path=[0] + ct := NewCallTracker() + + type expected struct { + depth uint64 + frameID uint32 + path []uint32 + } + + testCases := []expected{ + {0, 0, []uint32{0}}, // PUSH1 + {0, 0, []uint32{0}}, // CALL(A) + {1, 1, []uint32{0, 1}}, // ADD (inside A) + {1, 1, []uint32{0, 1}}, // CALL(B) + {2, 2, []uint32{0, 1, 2}}, // MUL (inside B) + {2, 2, []uint32{0, 1, 2}}, // CALL(C) + {3, 3, []uint32{0, 1, 2, 3}}, // SLOAD (inside C) + {3, 3, []uint32{0, 1, 2, 3}}, // RETURN (inside C) + {2, 2, []uint32{0, 1, 2}}, // ADD (back in B) + {2, 2, []uint32{0, 1, 2}}, // RETURN (inside B) + {1, 1, []uint32{0, 1}}, // POP (back in A) + {0, 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/transaction_processing.go b/pkg/processor/transaction/structlog/transaction_processing.go index b700cc6..366a9c2 100644 --- a/pkg/processor/transaction/structlog/transaction_processing.go +++ b/pkg/processor/transaction/structlog/transaction_processing.go @@ -32,6 +32,8 @@ type Structlog struct { 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"` } @@ -79,6 +81,9 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, // Compute actual gas used for each structlog gasUsed := ComputeGasUsed(trace.Structlogs) + // Initialize call frame tracker + callTracker := NewCallTracker() + // Check if this is a big transaction and register if needed if totalCount >= p.bigTxManager.GetThreshold() { p.bigTxManager.RegisterBigTransaction(tx.Hash().String(), p) @@ -138,6 +143,9 @@ 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()), @@ -158,6 +166,8 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, Refund: trace.Structlogs[i].Refund, Error: trace.Structlogs[i].Error, CallToAddress: p.extractCallAddress(&trace.Structlogs[i]), + CallFrameID: frameID, + CallFramePath: framePath, MetaNetworkID: p.network.ID, MetaNetworkName: p.network.Name, }) @@ -227,15 +237,27 @@ 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. +// extractCallAddress extracts the call address from a structlog if it's a CALL-type operation. +// Handles CALL, CALLCODE, DELEGATECALL, and STATICCALL opcodes. 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": + // Stack: [gas, addr, value, argsOffset, argsSize, retOffset, retSize] stackValue := (*structLog.Stack)[len(*structLog.Stack)-2] return &stackValue - } + case "DELEGATECALL", "STATICCALL": + // Stack: [gas, addr, argsOffset, argsSize, retOffset, retSize] + stackValue := (*structLog.Stack)[len(*structLog.Stack)-2] - return nil + return &stackValue + default: + return nil + } } // ExtractStructlogs extracts structlog data from a transaction without inserting to database. @@ -272,16 +294,15 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i // Compute actual gas used for each structlog gasUsed := ComputeGasUsed(trace.Structlogs) + // Initialize call frame tracker + callTracker := NewCallTracker() + // 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()), @@ -301,7 +322,9 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i ReturnData: structLog.ReturnData, Refund: structLog.Refund, Error: structLog.Error, - CallToAddress: callToAddress, + CallToAddress: p.extractCallAddress(&structLog), + CallFrameID: frameID, + CallFramePath: framePath, MetaNetworkID: p.network.ID, MetaNetworkName: p.network.Name, } From 3b880bc6273dae6da860d3d9544b20456f66d169 Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Wed, 14 Jan 2026 10:18:44 +1000 Subject: [PATCH 2/8] test: add comprehensive unit tests for extractCallAddress function --- .../structlog/extract_call_address_test.go | 211 ++++++++++++++++++ .../structlog/transaction_processing.go | 1 + 2 files changed, 212 insertions(+) create mode 100644 pkg/processor/transaction/structlog/extract_call_address_test.go 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..9d921c1 --- /dev/null +++ b/pkg/processor/transaction/structlog/extract_call_address_test.go @@ -0,0 +1,211 @@ +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: [gas, addr, value, argsOffset, argsSize, retOffset, retSize] + // Address is at position len-2 (second from top) + stack := []string{ + "0x5208", // gas + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr (target) + "0x0", // value + "0x0", // argsOffset + "0x0", // argsSize + "0x0", // retOffset + "0x0", // retSize + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x0", *result) // Second from top is retOffset (0x0) +} + +func TestExtractCallAddress_CALL_MinimalStack(t *testing.T) { + p := &Processor{} + // Minimal stack with just 2 elements + stack := []string{ + "0x5208", // gas + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x5208", *result) // Second from top +} + +func TestExtractCallAddress_CALLCODE(t *testing.T) { + p := &Processor{} + // CALLCODE has same stack layout as CALL + stack := []string{ + "0x5208", + "0xdeadbeef", + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALLCODE", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x5208", *result) +} + +func TestExtractCallAddress_DELEGATECALL(t *testing.T) { + p := &Processor{} + // DELEGATECALL stack: [gas, addr, argsOffset, argsSize, retOffset, retSize] + // (no value parameter) + stack := []string{ + "0x5208", + "0xdeadbeef", + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "DELEGATECALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x5208", *result) +} + +func TestExtractCallAddress_STATICCALL(t *testing.T) { + p := &Processor{} + // STATICCALL has same stack layout as DELEGATECALL + stack := []string{ + "0x5208", + "0xdeadbeef", + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "STATICCALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x5208", *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 receipt) + "CREATE2", // CREATE2 is not handled (address comes from receipt) + } + + 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_AllCallVariants(t *testing.T) { + // Table-driven test for all supported CALL variants + p := &Processor{} + + testCases := []struct { + name string + op string + stack []string + expected string + }{ + { + name: "CALL with full stack", + op: "CALL", + stack: []string{"0xgas", "0xaddr", "0xvalue", "0xargsOff", "0xargsSize", "0xretOff", "0xretSize"}, + expected: "0xretOff", // len-2 position + }, + { + name: "CALLCODE with full stack", + op: "CALLCODE", + stack: []string{"0xgas", "0xaddr", "0xvalue", "0xargsOff", "0xargsSize", "0xretOff", "0xretSize"}, + expected: "0xretOff", + }, + { + name: "DELEGATECALL with full stack", + op: "DELEGATECALL", + stack: []string{"0xgas", "0xaddr", "0xargsOff", "0xargsSize", "0xretOff", "0xretSize"}, + expected: "0xretOff", + }, + { + name: "STATICCALL with full stack", + op: "STATICCALL", + stack: []string{"0xgas", "0xaddr", "0xargsOff", "0xargsSize", "0xretOff", "0xretSize"}, + expected: "0xretOff", + }, + } + + 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, tc.expected, *result) + }) + } +} diff --git a/pkg/processor/transaction/structlog/transaction_processing.go b/pkg/processor/transaction/structlog/transaction_processing.go index 366a9c2..e3c9419 100644 --- a/pkg/processor/transaction/structlog/transaction_processing.go +++ b/pkg/processor/transaction/structlog/transaction_processing.go @@ -142,6 +142,7 @@ 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) From fe336bf15cb9090b7e61ec0befe61c3c8a1f1142 Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Wed, 14 Jan 2026 10:27:09 +1000 Subject: [PATCH 3/8] test: add unit tests for CREATE/CREATE2 address extraction feat: detect CREATE/CREATE2 opcodes and fetch contract address from receipt refactor: replace extractCallAddress with extractCallAddressWithCreate to handle contract creation addresses --- .../structlog/create_address_test.go | 202 ++++++++++++++++++ .../structlog/transaction_processing.go | 76 ++++++- 2 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 pkg/processor/transaction/structlog/create_address_test.go 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..c1ecd72 --- /dev/null +++ b/pkg/processor/transaction/structlog/create_address_test.go @@ -0,0 +1,202 @@ +package structlog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" +) + +const testCreateAddress = "0x1234567890abcdef1234567890abcdef12345678" + +func TestHasCreateOpcode_Empty(t *testing.T) { + result := hasCreateOpcode([]execution.StructLog{}) + assert.False(t, result) +} + +func TestHasCreateOpcode_NoCREATE(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "PUSH1"}, + {Op: "CALL"}, + {Op: "ADD"}, + {Op: "SLOAD"}, + {Op: "RETURN"}, + } + + result := hasCreateOpcode(structlogs) + assert.False(t, result) +} + +func TestHasCreateOpcode_HasCREATE(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "PUSH1"}, + {Op: "CREATE"}, + {Op: "POP"}, + } + + result := hasCreateOpcode(structlogs) + assert.True(t, result) +} + +func TestHasCreateOpcode_HasCREATE2(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "PUSH1"}, + {Op: "CREATE2"}, + {Op: "POP"}, + } + + result := hasCreateOpcode(structlogs) + assert.True(t, result) +} + +func TestHasCreateOpcode_BothCREATEAndCREATE2(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "CREATE"}, + {Op: "CREATE2"}, + } + + result := hasCreateOpcode(structlogs) + assert.True(t, result) +} + +func TestHasCreateOpcode_CREATEAtEnd(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "PUSH1"}, + {Op: "PUSH2"}, + {Op: "ADD"}, + {Op: "CREATE"}, + } + + result := hasCreateOpcode(structlogs) + assert.True(t, result) +} + +func TestExtractCallAddressWithCreate_CREATE(t *testing.T) { + p := &Processor{} + createAddr := testCreateAddress + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE", + }, &createAddr) + + assert.NotNil(t, result) + assert.Equal(t, createAddr, *result) +} + +func TestExtractCallAddressWithCreate_CREATE2(t *testing.T) { + p := &Processor{} + createAddr := "0xabcdef1234567890abcdef1234567890abcdef12" + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE2", + }, &createAddr) + + assert.NotNil(t, result) + assert.Equal(t, createAddr, *result) +} + +func TestExtractCallAddressWithCreate_CREATEWithNilAddress(t *testing.T) { + p := &Processor{} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE", + }, nil) + + assert.Nil(t, result) +} + +func TestExtractCallAddressWithCreate_CREATE2WithNilAddress(t *testing.T) { + p := &Processor{} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE2", + }, nil) + + assert.Nil(t, result) +} + +func TestExtractCallAddressWithCreate_CALLDelegatesToExtractCallAddress(t *testing.T) { + p := &Processor{} + createAddr := testCreateAddress + stack := []string{"0x5208", "0xdeadbeef"} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }, &createAddr) + + // Should use extractCallAddress, not createAddr + assert.NotNil(t, result) + assert.Equal(t, "0x5208", *result) // Second from top of stack +} + +func TestExtractCallAddressWithCreate_DELEGATECALLDelegatesToExtractCallAddress(t *testing.T) { + p := &Processor{} + createAddr := testCreateAddress + stack := []string{"0x5208", "0xdeadbeef"} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "DELEGATECALL", + Stack: &stack, + }, &createAddr) + + // Should use extractCallAddress, not createAddr + assert.NotNil(t, result) + assert.Equal(t, "0x5208", *result) +} + +func TestExtractCallAddressWithCreate_STATICCALLDelegatesToExtractCallAddress(t *testing.T) { + p := &Processor{} + createAddr := testCreateAddress + stack := []string{"0x5208", "0xdeadbeef"} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "STATICCALL", + Stack: &stack, + }, &createAddr) + + assert.NotNil(t, result) + assert.Equal(t, "0x5208", *result) +} + +func TestExtractCallAddressWithCreate_CALLCODEDelegatesToExtractCallAddress(t *testing.T) { + p := &Processor{} + createAddr := testCreateAddress + stack := []string{"0x5208", "0xdeadbeef"} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CALLCODE", + Stack: &stack, + }, &createAddr) + + assert.NotNil(t, result) + assert.Equal(t, "0x5208", *result) +} + +func TestExtractCallAddressWithCreate_NonCallOpcodeReturnsNil(t *testing.T) { + p := &Processor{} + createAddr := 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, + }, &createAddr) + + assert.Nil(t, result, "opcode %s should return nil", op) + }) + } +} diff --git a/pkg/processor/transaction/structlog/transaction_processing.go b/pkg/processor/transaction/structlog/transaction_processing.go index e3c9419..1487ac3 100644 --- a/pkg/processor/transaction/structlog/transaction_processing.go +++ b/pkg/processor/transaction/structlog/transaction_processing.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/sirupsen/logrus" @@ -84,6 +85,19 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, // Initialize call frame tracker callTracker := NewCallTracker() + // Fetch CREATE address from receipt if trace contains CREATE/CREATE2 opcodes + var createAddress *string + + if hasCreateOpcode(trace.Structlogs) { + var err error + + createAddress, err = p.fetchCreateAddress(ctx, tx.Hash().String()) + if err != nil { + p.log.WithError(err).Warn("Failed to fetch CREATE address from receipt") + // Continue without CREATE address - not fatal + } + } + // Check if this is a big transaction and register if needed if totalCount >= p.bigTxManager.GetThreshold() { p.bigTxManager.RegisterBigTransaction(tx.Hash().String(), p) @@ -166,7 +180,7 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, 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], createAddress), CallFrameID: frameID, CallFramePath: framePath, MetaNetworkID: p.network.ID, @@ -240,6 +254,7 @@ func (p *Processor) getTransactionTrace(ctx context.Context, tx *types.Transacti // extractCallAddress extracts the call address from a structlog if it's a CALL-type operation. // Handles CALL, CALLCODE, DELEGATECALL, and STATICCALL opcodes. +// For CREATE/CREATE2, use extractCallAddressWithCreate instead. func (p *Processor) extractCallAddress(structLog *execution.StructLog) *string { if structLog.Stack == nil || len(*structLog.Stack) < 2 { return nil @@ -261,6 +276,50 @@ func (p *Processor) extractCallAddress(structLog *execution.StructLog) *string { } } +// extractCallAddressWithCreate extracts the call address, using createAddress for CREATE/CREATE2 opcodes. +func (p *Processor) extractCallAddressWithCreate(structLog *execution.StructLog, createAddress *string) *string { + // For CREATE/CREATE2, use the address from the receipt + if structLog.Op == "CREATE" || structLog.Op == "CREATE2" { + return createAddress + } + + return p.extractCallAddress(structLog) +} + +// hasCreateOpcode checks if any structlog contains a CREATE or CREATE2 opcode. +func hasCreateOpcode(structlogs []execution.StructLog) bool { + for i := range structlogs { + if structlogs[i].Op == "CREATE" || structlogs[i].Op == "CREATE2" { + return true + } + } + + return false +} + +// fetchCreateAddress fetches the contract address from the transaction receipt. +// Returns nil if the receipt has no contract address (not a contract creation tx). +func (p *Processor) fetchCreateAddress(ctx context.Context, txHash string) (*string, error) { + node := p.pool.GetHealthyExecutionNode() + if node == nil { + return nil, fmt.Errorf("no healthy execution node available") + } + + receipt, err := node.TransactionReceipt(ctx, txHash) + if err != nil { + return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err) + } + + // Check if contract was created (ContractAddress is non-zero) + if receipt.ContractAddress == (ethcommon.Address{}) { + return nil, nil //nolint:nilnil // nil address with nil error is valid - means no contract created + } + + addr := receipt.ContractAddress.Hex() + + return &addr, nil +} + // ExtractStructlogs extracts structlog data from a transaction without inserting to database. func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, index int, tx *types.Transaction) ([]Structlog, error) { start := time.Now() @@ -298,6 +357,19 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i // Initialize call frame tracker callTracker := NewCallTracker() + // Fetch CREATE address from receipt if trace contains CREATE/CREATE2 opcodes + var createAddress *string + + if hasCreateOpcode(trace.Structlogs) { + var err error + + createAddress, err = p.fetchCreateAddress(ctx, tx.Hash().String()) + if err != nil { + p.log.WithError(err).Warn("Failed to fetch CREATE address from receipt") + // Continue without CREATE address - not fatal + } + } + // Pre-allocate slice for better memory efficiency structlogs = make([]Structlog, 0, len(trace.Structlogs)) @@ -323,7 +395,7 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i ReturnData: structLog.ReturnData, Refund: structLog.Refund, Error: structLog.Error, - CallToAddress: p.extractCallAddress(&structLog), + CallToAddress: p.extractCallAddressWithCreate(&structLog, createAddress), CallFrameID: frameID, CallFramePath: framePath, MetaNetworkID: p.network.ID, From 2e59fcf30fb3e00197e1841c1a1f6031f0220246 Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Wed, 14 Jan 2026 14:08:54 +1000 Subject: [PATCH 4/8] style(transaction_processing.go): move comment to line above log to match Go style --- pkg/processor/transaction/structlog/transaction_processing.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/processor/transaction/structlog/transaction_processing.go b/pkg/processor/transaction/structlog/transaction_processing.go index 1487ac3..784c877 100644 --- a/pkg/processor/transaction/structlog/transaction_processing.go +++ b/pkg/processor/transaction/structlog/transaction_processing.go @@ -93,8 +93,8 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, createAddress, err = p.fetchCreateAddress(ctx, tx.Hash().String()) if err != nil { + // Continue without CREATE address - not fatal. p.log.WithError(err).Warn("Failed to fetch CREATE address from receipt") - // Continue without CREATE address - not fatal } } From a43dac7e05a82177fb59b451abbc3c42986d135c Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Wed, 14 Jan 2026 14:26:07 +1000 Subject: [PATCH 5/8] refactor(processor): replace receipt-based CREATE address lookup with trace-based computation - Remove fetchCreateAddress and hasCreateOpcode helpers - Introduce ComputeCreateAddresses to extract addresses directly from trace - Update extractCallAddressWithCreate signature to accept index and map - Rename queue name from "transaction-structlog" to "transaction_structlog" - Expand test coverage for nested and failed CREATE scenarios --- pkg/processor/manager.go | 2 +- .../structlog/create_address_test.go | 220 +++++++++++------- .../structlog/transaction_processing.go | 107 ++++----- 3 files changed, 189 insertions(+), 140 deletions(-) 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/create_address_test.go b/pkg/processor/transaction/structlog/create_address_test.go index c1ecd72..1c82df1 100644 --- a/pkg/processor/transaction/structlog/create_address_test.go +++ b/pkg/processor/transaction/structlog/create_address_test.go @@ -4,171 +4,220 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" ) const testCreateAddress = "0x1234567890abcdef1234567890abcdef12345678" -func TestHasCreateOpcode_Empty(t *testing.T) { - result := hasCreateOpcode([]execution.StructLog{}) - assert.False(t, result) +func TestComputeCreateAddresses_Empty(t *testing.T) { + result := ComputeCreateAddresses([]execution.StructLog{}) + assert.Empty(t, result) } -func TestHasCreateOpcode_NoCREATE(t *testing.T) { +func TestComputeCreateAddresses_NoCREATE(t *testing.T) { structlogs := []execution.StructLog{ - {Op: "PUSH1"}, - {Op: "CALL"}, - {Op: "ADD"}, - {Op: "SLOAD"}, - {Op: "RETURN"}, + {Op: "PUSH1", Depth: 1}, + {Op: "CALL", Depth: 1}, + {Op: "ADD", Depth: 2}, + {Op: "RETURN", Depth: 2}, + {Op: "STOP", Depth: 1}, } - result := hasCreateOpcode(structlogs) - assert.False(t, result) + result := ComputeCreateAddresses(structlogs) + assert.Empty(t, result) } -func TestHasCreateOpcode_HasCREATE(t *testing.T) { +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"}, - {Op: "CREATE"}, - {Op: "POP"}, + {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 := hasCreateOpcode(structlogs) - assert.True(t, result) + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + assert.Equal(t, createdAddr, *result[1]) } -func TestHasCreateOpcode_HasCREATE2(t *testing.T) { +func TestComputeCreateAddresses_CREATE2(t *testing.T) { + createdAddr := "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + stack := []string{createdAddr} + structlogs := []execution.StructLog{ - {Op: "PUSH1"}, - {Op: "CREATE2"}, - {Op: "POP"}, + {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 := hasCreateOpcode(structlogs) - assert.True(t, result) + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + assert.Equal(t, createdAddr, *result[1]) } -func TestHasCreateOpcode_BothCREATEAndCREATE2(t *testing.T) { +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: "CREATE"}, - {Op: "CREATE2"}, + {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 := hasCreateOpcode(structlogs) - assert.True(t, result) + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + assert.Equal(t, zeroAddr, *result[1]) } -func TestHasCreateOpcode_CREATEAtEnd(t *testing.T) { +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"}, - {Op: "PUSH2"}, - {Op: "ADD"}, - {Op: "CREATE"}, + {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 := hasCreateOpcode(structlogs) - assert.True(t, result) + 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{} - createAddr := testCreateAddress + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } result := p.extractCallAddressWithCreate(&execution.StructLog{ Op: "CREATE", - }, &createAddr) + }, 0, createAddresses) assert.NotNil(t, result) - assert.Equal(t, createAddr, *result) + assert.Equal(t, testCreateAddress, *result) } func TestExtractCallAddressWithCreate_CREATE2(t *testing.T) { p := &Processor{} - createAddr := "0xabcdef1234567890abcdef1234567890abcdef12" + addr := "0xabcdef1234567890abcdef1234567890abcdef12" + createAddresses := map[int]*string{ + 5: ptrString(addr), + } result := p.extractCallAddressWithCreate(&execution.StructLog{ Op: "CREATE2", - }, &createAddr) + }, 5, createAddresses) assert.NotNil(t, result) - assert.Equal(t, createAddr, *result) + assert.Equal(t, addr, *result) } -func TestExtractCallAddressWithCreate_CREATEWithNilAddress(t *testing.T) { +func TestExtractCallAddressWithCreate_CREATEWithNilMap(t *testing.T) { p := &Processor{} result := p.extractCallAddressWithCreate(&execution.StructLog{ Op: "CREATE", - }, nil) + }, 0, nil) assert.Nil(t, result) } -func TestExtractCallAddressWithCreate_CREATE2WithNilAddress(t *testing.T) { +func TestExtractCallAddressWithCreate_CREATENotInMap(t *testing.T) { p := &Processor{} + createAddresses := map[int]*string{ + 10: ptrString(testCreateAddress), + } result := p.extractCallAddressWithCreate(&execution.StructLog{ - Op: "CREATE2", - }, nil) + Op: "CREATE", + }, 5, createAddresses) // index 5 not in map assert.Nil(t, result) } func TestExtractCallAddressWithCreate_CALLDelegatesToExtractCallAddress(t *testing.T) { p := &Processor{} - createAddr := testCreateAddress + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } stack := []string{"0x5208", "0xdeadbeef"} result := p.extractCallAddressWithCreate(&execution.StructLog{ Op: "CALL", Stack: &stack, - }, &createAddr) + }, 0, createAddresses) - // Should use extractCallAddress, not createAddr + // Should use extractCallAddress, not createAddresses assert.NotNil(t, result) assert.Equal(t, "0x5208", *result) // Second from top of stack } func TestExtractCallAddressWithCreate_DELEGATECALLDelegatesToExtractCallAddress(t *testing.T) { p := &Processor{} - createAddr := testCreateAddress + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } stack := []string{"0x5208", "0xdeadbeef"} result := p.extractCallAddressWithCreate(&execution.StructLog{ Op: "DELEGATECALL", Stack: &stack, - }, &createAddr) - - // Should use extractCallAddress, not createAddr - assert.NotNil(t, result) - assert.Equal(t, "0x5208", *result) -} - -func TestExtractCallAddressWithCreate_STATICCALLDelegatesToExtractCallAddress(t *testing.T) { - p := &Processor{} - createAddr := testCreateAddress - stack := []string{"0x5208", "0xdeadbeef"} - - result := p.extractCallAddressWithCreate(&execution.StructLog{ - Op: "STATICCALL", - Stack: &stack, - }, &createAddr) - - assert.NotNil(t, result) - assert.Equal(t, "0x5208", *result) -} - -func TestExtractCallAddressWithCreate_CALLCODEDelegatesToExtractCallAddress(t *testing.T) { - p := &Processor{} - createAddr := testCreateAddress - stack := []string{"0x5208", "0xdeadbeef"} - - result := p.extractCallAddressWithCreate(&execution.StructLog{ - Op: "CALLCODE", - Stack: &stack, - }, &createAddr) + }, 0, createAddresses) assert.NotNil(t, result) assert.Equal(t, "0x5208", *result) @@ -176,7 +225,9 @@ func TestExtractCallAddressWithCreate_CALLCODEDelegatesToExtractCallAddress(t *t func TestExtractCallAddressWithCreate_NonCallOpcodeReturnsNil(t *testing.T) { p := &Processor{} - createAddr := testCreateAddress + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } stack := []string{"0x5208", "0xdeadbeef"} testCases := []string{ @@ -194,9 +245,14 @@ func TestExtractCallAddressWithCreate_NonCallOpcodeReturnsNil(t *testing.T) { result := p.extractCallAddressWithCreate(&execution.StructLog{ Op: op, Stack: &stack, - }, &createAddr) + }, 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/transaction_processing.go b/pkg/processor/transaction/structlog/transaction_processing.go index 784c877..9426a7f 100644 --- a/pkg/processor/transaction/structlog/transaction_processing.go +++ b/pkg/processor/transaction/structlog/transaction_processing.go @@ -5,7 +5,6 @@ import ( "fmt" "time" - ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/sirupsen/logrus" @@ -85,18 +84,8 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, // Initialize call frame tracker callTracker := NewCallTracker() - // Fetch CREATE address from receipt if trace contains CREATE/CREATE2 opcodes - var createAddress *string - - if hasCreateOpcode(trace.Structlogs) { - var err error - - createAddress, err = p.fetchCreateAddress(ctx, tx.Hash().String()) - if err != nil { - // Continue without CREATE address - not fatal. - p.log.WithError(err).Warn("Failed to fetch CREATE address from receipt") - } - } + // 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() { @@ -180,7 +169,7 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, ReturnData: trace.Structlogs[i].ReturnData, Refund: trace.Structlogs[i].Refund, Error: trace.Structlogs[i].Error, - CallToAddress: p.extractCallAddressWithCreate(&trace.Structlogs[i], createAddress), + CallToAddress: p.extractCallAddressWithCreate(&trace.Structlogs[i], i, createAddresses), CallFrameID: frameID, CallFramePath: framePath, MetaNetworkID: p.network.ID, @@ -276,48 +265,62 @@ func (p *Processor) extractCallAddress(structLog *execution.StructLog) *string { } } -// extractCallAddressWithCreate extracts the call address, using createAddress for CREATE/CREATE2 opcodes. -func (p *Processor) extractCallAddressWithCreate(structLog *execution.StructLog, createAddress *string) *string { - // For CREATE/CREATE2, use the address from the receipt +// 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" { - return createAddress + if createAddresses != nil { + return createAddresses[index] + } + + return nil } return p.extractCallAddress(structLog) } -// hasCreateOpcode checks if any structlog contains a CREATE or CREATE2 opcode. -func hasCreateOpcode(structlogs []execution.StructLog) bool { - for i := range structlogs { - if structlogs[i].Op == "CREATE" || structlogs[i].Op == "CREATE2" { - return true - } - } - - return false -} +// 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) -// fetchCreateAddress fetches the contract address from the transaction receipt. -// Returns nil if the receipt has no contract address (not a contract creation tx). -func (p *Processor) fetchCreateAddress(ctx context.Context, txHash string) (*string, error) { - node := p.pool.GetHealthyExecutionNode() - if node == nil { - return nil, fmt.Errorf("no healthy execution node available") + // Track pending CREATE operations: (index, depth) + type pendingCreate struct { + index int + depth uint64 } - receipt, err := node.TransactionReceipt(ctx, txHash) - if err != nil { - return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err) - } + 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 := (*log.Stack)[len(*log.Stack)-1] + result[last.index] = &addr + } + + pending = pending[:len(pending)-1] + } else { + break + } + } - // Check if contract was created (ContractAddress is non-zero) - if receipt.ContractAddress == (ethcommon.Address{}) { - return nil, nil //nolint:nilnil // nil address with nil error is valid - means no contract created + // Track new CREATE/CREATE2 + if log.Op == "CREATE" || log.Op == "CREATE2" { + pending = append(pending, pendingCreate{index: i, depth: log.Depth}) + } } - addr := receipt.ContractAddress.Hex() - - return &addr, nil + return result } // ExtractStructlogs extracts structlog data from a transaction without inserting to database. @@ -357,18 +360,8 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i // Initialize call frame tracker callTracker := NewCallTracker() - // Fetch CREATE address from receipt if trace contains CREATE/CREATE2 opcodes - var createAddress *string - - if hasCreateOpcode(trace.Structlogs) { - var err error - - createAddress, err = p.fetchCreateAddress(ctx, tx.Hash().String()) - if err != nil { - p.log.WithError(err).Warn("Failed to fetch CREATE address from receipt") - // Continue without CREATE address - not fatal - } - } + // 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)) @@ -395,7 +388,7 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i ReturnData: structLog.ReturnData, Refund: structLog.Refund, Error: structLog.Error, - CallToAddress: p.extractCallAddressWithCreate(&structLog, createAddress), + CallToAddress: p.extractCallAddressWithCreate(&structLog, i, createAddresses), CallFrameID: frameID, CallFramePath: framePath, MetaNetworkID: p.network.ID, From b74db3621f5c2bac7953de9364c190c562df91c6 Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Wed, 14 Jan 2026 15:16:51 +1000 Subject: [PATCH 6/8] fix(call_tracker): align root frame depth with EVM traces (depth 1) The root frame now starts at depth 1 instead of 0 to match the actual EVM structlog output, where execution begins at depth 1. All tests updated to reflect the new depth semantics. --- .../transaction/structlog/call_tracker.go | 4 +- .../structlog/call_tracker_test.go | 166 +++++++++--------- 2 files changed, 86 insertions(+), 84 deletions(-) diff --git a/pkg/processor/transaction/structlog/call_tracker.go b/pkg/processor/transaction/structlog/call_tracker.go index 18ecca3..1ba386a 100644 --- a/pkg/processor/transaction/structlog/call_tracker.go +++ b/pkg/processor/transaction/structlog/call_tracker.go @@ -16,9 +16,11 @@ type CallTracker struct { } // 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: 0}}, + stack: []CallFrame{{ID: 0, Depth: 1}}, nextID: 1, path: []uint32{0}, } diff --git a/pkg/processor/transaction/structlog/call_tracker_test.go b/pkg/processor/transaction/structlog/call_tracker_test.go index 29dfb1a..810e3bf 100644 --- a/pkg/processor/transaction/structlog/call_tracker_test.go +++ b/pkg/processor/transaction/structlog/call_tracker_test.go @@ -17,16 +17,16 @@ func TestNewCallTracker(t *testing.T) { func TestCallTracker_SameDepth(t *testing.T) { ct := NewCallTracker() - // All opcodes at depth 0 should stay in frame 0 - frameID, path := ct.ProcessDepthChange(0) + // 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(0) + frameID, path = ct.ProcessDepthChange(1) assert.Equal(t, uint32(0), frameID) assert.Equal(t, []uint32{0}, path) - frameID, path = ct.ProcessDepthChange(0) + frameID, path = ct.ProcessDepthChange(1) assert.Equal(t, uint32(0), frameID) assert.Equal(t, []uint32{0}, path) } @@ -34,23 +34,23 @@ func TestCallTracker_SameDepth(t *testing.T) { func TestCallTracker_SingleCall(t *testing.T) { ct := NewCallTracker() - // depth=0: root frame - frameID, path := ct.ProcessDepthChange(0) + // 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=1: entering first call - frameID, path = ct.ProcessDepthChange(1) + // depth=2: entering first call + frameID, path = ct.ProcessDepthChange(2) assert.Equal(t, uint32(1), frameID) assert.Equal(t, []uint32{0, 1}, path) - // depth=1: still in first call - frameID, path = ct.ProcessDepthChange(1) + // 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=0: returned from call - frameID, path = ct.ProcessDepthChange(0) + // depth=1: returned from call + frameID, path = ct.ProcessDepthChange(1) assert.Equal(t, uint32(0), frameID) assert.Equal(t, []uint32{0}, path) } @@ -58,38 +58,38 @@ func TestCallTracker_SingleCall(t *testing.T) { func TestCallTracker_NestedCalls(t *testing.T) { ct := NewCallTracker() - // depth=0: root - frameID, path := ct.ProcessDepthChange(0) + // 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=1: first call - frameID, path = ct.ProcessDepthChange(1) + // depth=2: first call + frameID, path = ct.ProcessDepthChange(2) assert.Equal(t, uint32(1), frameID) assert.Equal(t, []uint32{0, 1}, path) - // depth=2: nested call - frameID, path = ct.ProcessDepthChange(2) + // depth=3: nested call + frameID, path = ct.ProcessDepthChange(3) assert.Equal(t, uint32(2), frameID) assert.Equal(t, []uint32{0, 1, 2}, path) - // depth=3: deeper nested call - frameID, path = ct.ProcessDepthChange(3) + // 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=2: return from depth 3 - frameID, path = ct.ProcessDepthChange(2) + // 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=1: return from depth 2 - frameID, path = ct.ProcessDepthChange(1) + // 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=0: return to root - frameID, path = ct.ProcessDepthChange(0) + // depth=1: return to root + frameID, path = ct.ProcessDepthChange(1) assert.Equal(t, uint32(0), frameID) assert.Equal(t, []uint32{0}, path) } @@ -100,43 +100,43 @@ func TestCallTracker_SiblingCalls(t *testing.T) { // root -> CALL (0xabc) -> CALL (0x456) -> CALL (0x789) ct := NewCallTracker() - // depth=0: root - frameID, path := ct.ProcessDepthChange(0) + // 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=1 (call to 0x123) - frameID, path = ct.ProcessDepthChange(1) + // 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=2 (call to 0x456) - frameID, path = ct.ProcessDepthChange(2) + // 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=3 (call to 0x789) - frameID, path = ct.ProcessDepthChange(3) + // 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(0) + frameID, path = ct.ProcessDepthChange(1) assert.Equal(t, uint32(0), frameID) assert.Equal(t, []uint32{0}, path) - // Second branch: depth=1 (call to 0xabc) - NEW frame_id! - frameID, path = ct.ProcessDepthChange(1) + // 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=2 (call to 0x456 again) - NEW frame_id! - frameID, path = ct.ProcessDepthChange(2) + // 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=3 (call to 0x789 again) - NEW frame_id! - frameID, path = ct.ProcessDepthChange(3) + // 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) } @@ -145,16 +145,16 @@ func TestCallTracker_MultipleReturns(t *testing.T) { // Test returning multiple levels at once (e.g., REVERT that unwinds multiple frames) ct := NewCallTracker() - // Build up: depth 0 -> 1 -> 2 -> 3 - ct.ProcessDepthChange(0) + // Build up: depth 1 -> 2 -> 3 -> 4 (EVM traces start at depth 1) ct.ProcessDepthChange(1) ct.ProcessDepthChange(2) - frameID, path := ct.ProcessDepthChange(3) + 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 3 to depth 1 (skipping depth 2) - frameID, path = ct.ProcessDepthChange(1) + // 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) } @@ -162,51 +162,51 @@ func TestCallTracker_MultipleReturns(t *testing.T) { func TestCallTracker_PathIsCopy(t *testing.T) { ct := NewCallTracker() - ct.ProcessDepthChange(0) - _, path1 := ct.ProcessDepthChange(1) + ct.ProcessDepthChange(1) + _, path1 := ct.ProcessDepthChange(2) // Modify path1, should not affect tracker's internal state path1[0] = 999 - _, path2 := ct.ProcessDepthChange(1) + _, 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) { - // Some EVM traces start at depth 1 instead of 0 + // EVM traces always start at depth 1, which is the root frame (ID 0) ct := NewCallTracker() - // First opcode at depth 1 - should create frame 1 + // First opcode at depth 1 - should be frame 0 (root) frameID, path := ct.ProcessDepthChange(1) - assert.Equal(t, uint32(1), frameID) - assert.Equal(t, []uint32{0, 1}, path) + 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(1), frameID) - assert.Equal(t, []uint32{0, 1}, path) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) - // Go deeper + // Go deeper - creates frame 1 frameID, path = ct.ProcessDepthChange(2) - assert.Equal(t, uint32(2), frameID) - assert.Equal(t, []uint32{0, 1, 2}, path) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) } func TestCallTracker_RealWorldExample(t *testing.T) { - // Simulate the example from the HackMD doc: - // op=PUSH1, depth=0 → frame_id=0, path=[0] - // op=CALL(A),depth=0 → frame_id=0, path=[0] - // op=ADD, depth=1 → frame_id=1, path=[0,1] - // op=CALL(B),d=1 → frame_id=1, path=[0,1] - // op=MUL, d=2 → frame_id=2, path=[0,1,2] - // op=CALL(C),d=2 → frame_id=2, path=[0,1,2] - // op=SLOAD,d=3 → frame_id=3, path=[0,1,2,3] - // op=RETURN,d=3 → frame_id=3, path=[0,1,2,3] - // op=ADD, d=2 → frame_id=2, path=[0,1,2] - // op=RETURN,d=2 → frame_id=2, path=[0,1,2] - // op=POP, depth=1 → frame_id=1, path=[0,1] - // op=STOP, depth=0 → frame_id=0, path=[0] + // 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 { @@ -216,18 +216,18 @@ func TestCallTracker_RealWorldExample(t *testing.T) { } testCases := []expected{ - {0, 0, []uint32{0}}, // PUSH1 - {0, 0, []uint32{0}}, // CALL(A) - {1, 1, []uint32{0, 1}}, // ADD (inside A) - {1, 1, []uint32{0, 1}}, // CALL(B) - {2, 2, []uint32{0, 1, 2}}, // MUL (inside B) - {2, 2, []uint32{0, 1, 2}}, // CALL(C) - {3, 3, []uint32{0, 1, 2, 3}}, // SLOAD (inside C) - {3, 3, []uint32{0, 1, 2, 3}}, // RETURN (inside C) - {2, 2, []uint32{0, 1, 2}}, // ADD (back in B) - {2, 2, []uint32{0, 1, 2}}, // RETURN (inside B) - {1, 1, []uint32{0, 1}}, // POP (back in A) - {0, 0, []uint32{0}}, // STOP (back in root) + {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 { From cbc694207afd77eb09b49bd900460e64b2a5b5c2 Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Fri, 16 Jan 2026 09:19:45 +1000 Subject: [PATCH 7/8] feat(structlog): add GasSelf field to isolate CALL/CREATE overhead from child gas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce GasSelf to represent the gas consumed by an opcode *excluding* any gas spent in child frames. For CALL/CREATE opcodes this yields the pure call overhead (warm/cold access, memory expansion, value transfer); for all other opcodes GasSelf equals GasUsed. This allows accurate aggregation of total execution gas without double counting. - Add ComputeGasSelf() helper that subtracts the sum of *direct* child GasUsed values from each CALL/CREATE opcode’s GasUsed. - Extend Structlog struct and ClickHouse schema with new GasSelf column. - Update both ProcessTransaction() and ExtractStructlogs() to populate the new field. - Provide extensive unit tests covering nested, sibling and edge cases. --- .../transaction/structlog/gas_cost.go | 105 +++++++ .../transaction/structlog/gas_cost_test.go | 283 ++++++++++++++++++ .../structlog/transaction_processing.go | 50 +++- 3 files changed, 426 insertions(+), 12 deletions(-) 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 9426a7f..2bdf403 100644 --- a/pkg/processor/transaction/structlog/transaction_processing.go +++ b/pkg/processor/transaction/structlog/transaction_processing.go @@ -12,6 +12,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,18 +27,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"` - CallFrameID uint32 `json:"call_frame_id"` - CallFramePath []uint32 `json:"call_frame_path"` - 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. @@ -81,6 +99,9 @@ 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() @@ -165,6 +186,7 @@ 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, @@ -357,6 +379,9 @@ 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() @@ -384,6 +409,7 @@ 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, From 618519b55dc4ab06d2c03eb01fd19be72809f00b Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Mon, 19 Jan 2026 12:42:06 +1000 Subject: [PATCH 8/8] test(structlog): fix and expand address extraction tests for CALL opcodes test(structlog): add comprehensive format_address tests for 20-byte padding refactor(structlog): introduce formatAddress to normalize stack values to 42-char addresses --- .../structlog/create_address_test.go | 10 +- .../structlog/extract_call_address_test.go | 153 ++++++++++++------ .../structlog/format_address_test.go | 115 +++++++++++++ .../structlog/transaction_processing.go | 70 ++++++-- 4 files changed, 288 insertions(+), 60 deletions(-) create mode 100644 pkg/processor/transaction/structlog/format_address_test.go diff --git a/pkg/processor/transaction/structlog/create_address_test.go b/pkg/processor/transaction/structlog/create_address_test.go index 1c82df1..b77f466 100644 --- a/pkg/processor/transaction/structlog/create_address_test.go +++ b/pkg/processor/transaction/structlog/create_address_test.go @@ -45,6 +45,7 @@ func TestComputeCreateAddresses_SingleCREATE(t *testing.T) { result := ComputeCreateAddresses(structlogs) require.Contains(t, result, 1) + // Address is already 40 chars, so stays the same assert.Equal(t, createdAddr, *result[1]) } @@ -80,7 +81,8 @@ func TestComputeCreateAddresses_FailedCREATE(t *testing.T) { result := ComputeCreateAddresses(structlogs) require.Contains(t, result, 1) - assert.Equal(t, zeroAddr, *result[1]) + // Zero address is zero-padded to 40 hex chars + assert.Equal(t, "0x0000000000000000000000000000000000000000", *result[1]) } func TestComputeCreateAddresses_NestedCREATEs(t *testing.T) { @@ -204,7 +206,8 @@ func TestExtractCallAddressWithCreate_CALLDelegatesToExtractCallAddress(t *testi // Should use extractCallAddress, not createAddresses assert.NotNil(t, result) - assert.Equal(t, "0x5208", *result) // Second from top of stack + // Second from top of stack, zero-padded to 40 hex chars + assert.Equal(t, "0x0000000000000000000000000000000000005208", *result) } func TestExtractCallAddressWithCreate_DELEGATECALLDelegatesToExtractCallAddress(t *testing.T) { @@ -220,7 +223,8 @@ func TestExtractCallAddressWithCreate_DELEGATECALLDelegatesToExtractCallAddress( }, 0, createAddresses) assert.NotNil(t, result) - assert.Equal(t, "0x5208", *result) + // Zero-padded to 40 hex chars + assert.Equal(t, "0x0000000000000000000000000000000000005208", *result) } func TestExtractCallAddressWithCreate_NonCallOpcodeReturnsNil(t *testing.T) { diff --git a/pkg/processor/transaction/structlog/extract_call_address_test.go b/pkg/processor/transaction/structlog/extract_call_address_test.go index 9d921c1..d72cb7f 100644 --- a/pkg/processor/transaction/structlog/extract_call_address_test.go +++ b/pkg/processor/transaction/structlog/extract_call_address_test.go @@ -45,16 +45,17 @@ func TestExtractCallAddress_InsufficientStack(t *testing.T) { func TestExtractCallAddress_CALL(t *testing.T) { p := &Processor{} - // CALL stack: [gas, addr, value, argsOffset, argsSize, retOffset, retSize] - // Address is at position len-2 (second from top) + // 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{ - "0x5208", // gas - "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr (target) - "0x0", // value - "0x0", // argsOffset - "0x0", // argsSize + "0x0", // retSize (bottom, index 0) "0x0", // retOffset - "0x0", // retSize + "0x0", // argsSize + "0x0", // argsOffset + "0x0", // value + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr (index len-2) + "0x5208", // gas (top, index len-1) } result := p.extractCallAddress(&execution.StructLog{ @@ -63,15 +64,15 @@ func TestExtractCallAddress_CALL(t *testing.T) { }) assert.NotNil(t, result) - assert.Equal(t, "0x0", *result) // Second from top is retOffset (0x0) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) } func TestExtractCallAddress_CALL_MinimalStack(t *testing.T) { p := &Processor{} - // Minimal stack with just 2 elements + // Minimal stack with just 2 elements (addr at index 0, gas at index 1) stack := []string{ - "0x5208", // gas - "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr (index 0 = len-2) + "0x5208", // gas (index 1 = len-1) } result := p.extractCallAddress(&execution.StructLog{ @@ -80,15 +81,40 @@ func TestExtractCallAddress_CALL_MinimalStack(t *testing.T) { }) assert.NotNil(t, result) - assert.Equal(t, "0x5208", *result) // Second from top + 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{ - "0x5208", - "0xdeadbeef", + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + "0x5208", // gas } result := p.extractCallAddress(&execution.StructLog{ @@ -97,16 +123,16 @@ func TestExtractCallAddress_CALLCODE(t *testing.T) { }) assert.NotNil(t, result) - assert.Equal(t, "0x5208", *result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) } func TestExtractCallAddress_DELEGATECALL(t *testing.T) { p := &Processor{} - // DELEGATECALL stack: [gas, addr, argsOffset, argsSize, retOffset, retSize] - // (no value parameter) + // DELEGATECALL stack (no value parameter, but addr still at len-2): + // [retSize, retOffset, argsSize, argsOffset, addr, gas] stack := []string{ - "0x5208", - "0xdeadbeef", + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + "0x5208", // gas } result := p.extractCallAddress(&execution.StructLog{ @@ -115,15 +141,15 @@ func TestExtractCallAddress_DELEGATECALL(t *testing.T) { }) assert.NotNil(t, result) - assert.Equal(t, "0x5208", *result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) } func TestExtractCallAddress_STATICCALL(t *testing.T) { p := &Processor{} // STATICCALL has same stack layout as DELEGATECALL stack := []string{ - "0x5208", - "0xdeadbeef", + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + "0x5208", // gas } result := p.extractCallAddress(&execution.StructLog{ @@ -132,7 +158,7 @@ func TestExtractCallAddress_STATICCALL(t *testing.T) { }) assert.NotNil(t, result) - assert.Equal(t, "0x5208", *result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) } func TestExtractCallAddress_NonCallOpcode(t *testing.T) { @@ -147,8 +173,8 @@ func TestExtractCallAddress_NonCallOpcode(t *testing.T) { "JUMP", "RETURN", "REVERT", - "CREATE", // CREATE is not handled (address comes from receipt) - "CREATE2", // CREATE2 is not handled (address comes from receipt) + "CREATE", // CREATE is not handled (address comes from trace) + "CREATE2", // CREATE2 is not handled (address comes from trace) } for _, op := range testCases { @@ -162,39 +188,72 @@ func TestExtractCallAddress_NonCallOpcode(t *testing.T) { } } +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 - expected string + 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{"0xgas", "0xaddr", "0xvalue", "0xargsOff", "0xargsSize", "0xretOff", "0xretSize"}, - expected: "0xretOff", // len-2 position + 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{"0xgas", "0xaddr", "0xvalue", "0xargsOff", "0xargsSize", "0xretOff", "0xretSize"}, - expected: "0xretOff", + 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{"0xgas", "0xaddr", "0xargsOff", "0xargsSize", "0xretOff", "0xretSize"}, - expected: "0xretOff", + name: "DELEGATECALL with full stack", + op: "DELEGATECALL", + stack: []string{"0xretSize", "0xretOff", "0xargsSize", "0xargsOff", targetAddr, "0xgas"}, }, { - name: "STATICCALL with full stack", - op: "STATICCALL", - stack: []string{"0xgas", "0xaddr", "0xargsOff", "0xargsSize", "0xretOff", "0xretSize"}, - expected: "0xretOff", + name: "STATICCALL with full stack", + op: "STATICCALL", + stack: []string{"0xretSize", "0xretOff", "0xargsSize", "0xargsOff", targetAddr, "0xgas"}, }, } @@ -205,7 +264,7 @@ func TestExtractCallAddress_AllCallVariants(t *testing.T) { Stack: &tc.stack, }) assert.NotNil(t, result) - assert.Equal(t, tc.expected, *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/transaction_processing.go b/pkg/processor/transaction/structlog/transaction_processing.go index 2bdf403..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" @@ -263,25 +264,74 @@ 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-type 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.Stack == nil || len(*structLog.Stack) < 2 { return nil } switch structLog.Op { - case "CALL", "CALLCODE": - // Stack: [gas, addr, value, argsOffset, argsSize, retOffset, retSize] - stackValue := (*structLog.Stack)[len(*structLog.Stack)-2] - - return &stackValue - case "DELEGATECALL", "STATICCALL": - // Stack: [gas, addr, argsOffset, argsSize, retOffset, retSize] + 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 &stackValue + return &addr default: return nil } @@ -326,7 +376,7 @@ func ComputeCreateAddresses(structlogs []execution.StructLog) map[int]*string { 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 := (*log.Stack)[len(*log.Stack)-1] + addr := formatAddress((*log.Stack)[len(*log.Stack)-1]) result[last.index] = &addr }