diff --git a/go.mod b/go.mod index bf07bc0..be59123 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/VictoriaMetrics/fastcache v1.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -31,20 +32,31 @@ require ( github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/emicklei/dot v1.6.2 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect + github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect github.com/ethereum/go-verkle v0.2.2 // indirect + github.com/ferranbt/fastssz v0.1.4 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gofrs/flock v0.12.1 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/holiman/bloomfilter/v2 v2.0.3 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/minio/sha256-simd v1.0.0 // indirect + github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.64.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/spf13/cast v1.7.0 // indirect @@ -60,4 +72,5 @@ require ( golang.org/x/sys v0.37.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 101195b..900f098 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608 github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8= +github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM= @@ -108,6 +110,7 @@ github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7Bd github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -129,6 +132,7 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= @@ -164,6 +168,8 @@ github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQP github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= +github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= diff --git a/pkg/processor/manager.go b/pkg/processor/manager.go index 5ce39b6..1820adc 100644 --- a/pkg/processor/manager.go +++ b/pkg/processor/manager.go @@ -944,7 +944,7 @@ func (m *Manager) shouldSkipBlockProcessing(ctx context.Context) (bool, string) // GetQueueName returns the current queue name based on processing mode. func (m *Manager) GetQueueName() string { // For now we only have one processor - processorName := "transaction-structlog" + processorName := "transaction_structlog" if m.config.Mode == c.BACKWARDS_MODE { return c.PrefixedProcessBackwardsQueue(processorName, m.redisPrefix) } diff --git a/pkg/processor/transaction/structlog/call_tracker.go b/pkg/processor/transaction/structlog/call_tracker.go new file mode 100644 index 0000000..805eb51 --- /dev/null +++ b/pkg/processor/transaction/structlog/call_tracker.go @@ -0,0 +1,81 @@ +package structlog + +// CallFrame represents a single call frame in the EVM execution. +type CallFrame struct { + ID uint32 // Sequential frame ID within the transaction + Depth uint64 // EVM depth level +} + +// CallTracker tracks call frames during EVM opcode traversal. +// It assigns sequential frame IDs as calls are entered and maintains +// the current path from root to the active frame. +type CallTracker struct { + stack []CallFrame // Stack of active call frames + nextID uint32 // Next frame ID to assign + path []uint32 // Current path from root to active frame +} + +// NewCallTracker creates a new CallTracker initialized with the root frame. +// The root frame has ID 0 and Depth 1, matching EVM structlog traces where +// execution starts at depth 1 (not 0). +func NewCallTracker() *CallTracker { + return &CallTracker{ + stack: []CallFrame{{ID: 0, Depth: 1}}, + nextID: 1, + path: []uint32{0}, + } +} + +// ProcessDepthChange processes a depth change and returns the current frame ID and path. +// Call this for each opcode with the opcode's depth value. +func (ct *CallTracker) ProcessDepthChange(newDepth uint64) (frameID uint32, framePath []uint32) { + currentDepth := ct.stack[len(ct.stack)-1].Depth + + if newDepth > currentDepth { + // Entering new call frame + newFrame := CallFrame{ID: ct.nextID, Depth: newDepth} + ct.stack = append(ct.stack, newFrame) + ct.path = append(ct.path, ct.nextID) + ct.nextID++ + } else if newDepth < currentDepth { + // Returning from call(s) - pop frames until depth matches + for len(ct.stack) > 1 && ct.stack[len(ct.stack)-1].Depth > newDepth { + ct.stack = ct.stack[:len(ct.stack)-1] + ct.path = ct.path[:len(ct.path)-1] + } + } + + // Return current frame info (copy path to avoid mutation issues) + pathCopy := make([]uint32, len(ct.path)) + copy(pathCopy, ct.path) + + return ct.stack[len(ct.stack)-1].ID, pathCopy +} + +// IssueFrameID allocates the next frame ID without processing a depth change. +// Used for synthetic frames (e.g., EOA calls that don't increase depth). +// Returns the new frame ID and the path for the synthetic child frame. +func (ct *CallTracker) IssueFrameID() (frameID uint32, framePath []uint32) { + newID := ct.nextID + ct.nextID++ + + // Path for synthetic frame is current path + new ID + pathCopy := make([]uint32, len(ct.path)+1) + copy(pathCopy, ct.path) + pathCopy[len(ct.path)] = newID + + return newID, 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..0fda1d0 --- /dev/null +++ b/pkg/processor/transaction/structlog/call_tracker_test.go @@ -0,0 +1,640 @@ +package structlog + +import ( + "testing" + + "github.com/ethereum/go-ethereum/core/vm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCallTracker(t *testing.T) { + ct := NewCallTracker() + + assert.Equal(t, uint32(0), ct.CurrentFrameID()) + assert.Equal(t, []uint32{0}, ct.CurrentPath()) +} + +func TestCallTracker_SameDepth(t *testing.T) { + ct := NewCallTracker() + + // All opcodes at depth 1 should stay in frame 0 (root) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) +} + +func TestCallTracker_SingleCall(t *testing.T) { + ct := NewCallTracker() + + // depth=1: root frame (EVM traces start at depth 1) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // depth=2: entering first call + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=2: still in first call + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=1: returned from call + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) +} + +func TestCallTracker_NestedCalls(t *testing.T) { + ct := NewCallTracker() + + // depth=1: root (EVM traces start at depth 1) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // depth=2: first call + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=3: nested call + frameID, path = ct.ProcessDepthChange(3) + assert.Equal(t, uint32(2), frameID) + assert.Equal(t, []uint32{0, 1, 2}, path) + + // depth=4: deeper nested call + frameID, path = ct.ProcessDepthChange(4) + assert.Equal(t, uint32(3), frameID) + assert.Equal(t, []uint32{0, 1, 2, 3}, path) + + // depth=3: return from depth 4 + frameID, path = ct.ProcessDepthChange(3) + assert.Equal(t, uint32(2), frameID) + assert.Equal(t, []uint32{0, 1, 2}, path) + + // depth=2: return from depth 3 + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=1: return to root + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) +} + +func TestCallTracker_SiblingCalls(t *testing.T) { + // Tests the scenario from the plan: + // root -> CALL (0x123) -> CALL (0x456) -> CALL (0x789) + // root -> CALL (0xabc) -> CALL (0x456) -> CALL (0x789) + ct := NewCallTracker() + + // depth=1: root (EVM traces start at depth 1) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // First branch: depth=2 (call to 0x123) + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) + + // depth=3 (call to 0x456) + frameID, path = ct.ProcessDepthChange(3) + assert.Equal(t, uint32(2), frameID) + assert.Equal(t, []uint32{0, 1, 2}, path) + + // depth=4 (call to 0x789) + frameID, path = ct.ProcessDepthChange(4) + assert.Equal(t, uint32(3), frameID) + assert.Equal(t, []uint32{0, 1, 2, 3}, path) + + // Return all the way to root + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // Second branch: depth=2 (call to 0xabc) - NEW frame_id! + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(4), frameID, "sibling call should get new frame_id") + assert.Equal(t, []uint32{0, 4}, path) + + // depth=3 (call to 0x456 again) - NEW frame_id! + frameID, path = ct.ProcessDepthChange(3) + assert.Equal(t, uint32(5), frameID, "same contract different call should get new frame_id") + assert.Equal(t, []uint32{0, 4, 5}, path) + + // depth=4 (call to 0x789 again) - NEW frame_id! + frameID, path = ct.ProcessDepthChange(4) + assert.Equal(t, uint32(6), frameID, "same contract different call should get new frame_id") + assert.Equal(t, []uint32{0, 4, 5, 6}, path) +} + +func TestCallTracker_MultipleReturns(t *testing.T) { + // Test returning multiple levels at once (e.g., REVERT that unwinds multiple frames) + ct := NewCallTracker() + + // Build up: depth 1 -> 2 -> 3 -> 4 (EVM traces start at depth 1) + ct.ProcessDepthChange(1) + ct.ProcessDepthChange(2) + ct.ProcessDepthChange(3) + frameID, path := ct.ProcessDepthChange(4) + assert.Equal(t, uint32(3), frameID) + assert.Equal(t, []uint32{0, 1, 2, 3}, path) + + // Jump directly from depth 4 to depth 2 (skipping depth 3) + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) +} + +func TestCallTracker_PathIsCopy(t *testing.T) { + ct := NewCallTracker() + + ct.ProcessDepthChange(1) + _, path1 := ct.ProcessDepthChange(2) + + // Modify path1, should not affect tracker's internal state + path1[0] = 999 + + _, path2 := ct.ProcessDepthChange(2) + require.Len(t, path2, 2) + assert.Equal(t, uint32(0), path2[0], "modifying returned path should not affect tracker") +} + +func TestCallTracker_DepthStartsAtOne(t *testing.T) { + // EVM traces always start at depth 1, which is the root frame (ID 0) + ct := NewCallTracker() + + // First opcode at depth 1 - should be frame 0 (root) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // Stay at depth 1 + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // Go deeper - creates frame 1 + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(1), frameID) + assert.Equal(t, []uint32{0, 1}, path) +} + +func TestCallTracker_IssueFrameID(t *testing.T) { + // Tests IssueFrameID for synthetic EOA frames + ct := NewCallTracker() + + // depth=1: root (EVM traces start at depth 1) + frameID, path := ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // Issue a synthetic frame for EOA call (no depth increase) + eoaFrameID, eoaPath := ct.IssueFrameID() + assert.Equal(t, uint32(1), eoaFrameID, "EOA frame should get next sequential ID") + assert.Equal(t, []uint32{0, 1}, eoaPath, "EOA path should be parent path + EOA ID") + + // Regular depth increase should get the next ID after the EOA frame + frameID, path = ct.ProcessDepthChange(2) + assert.Equal(t, uint32(2), frameID, "next real frame should get ID after EOA frame") + assert.Equal(t, []uint32{0, 2}, path) + + // Issue another EOA frame from depth 2 + eoaFrameID2, eoaPath2 := ct.IssueFrameID() + assert.Equal(t, uint32(3), eoaFrameID2) + assert.Equal(t, []uint32{0, 2, 3}, eoaPath2) + + // Return to depth 1 + frameID, path = ct.ProcessDepthChange(1) + assert.Equal(t, uint32(0), frameID) + assert.Equal(t, []uint32{0}, path) + + // Issue EOA from root - should continue sequential numbering + eoaFrameID3, eoaPath3 := ct.IssueFrameID() + assert.Equal(t, uint32(4), eoaFrameID3) + assert.Equal(t, []uint32{0, 4}, eoaPath3) +} + +func TestCallTracker_IssueFrameID_MultipleConsecutive(t *testing.T) { + // Test multiple consecutive EOA calls (e.g., contract sends to multiple EOAs) + ct := NewCallTracker() + + ct.ProcessDepthChange(1) // root + + // Three consecutive EOA calls + id1, path1 := ct.IssueFrameID() + id2, path2 := ct.IssueFrameID() + id3, path3 := ct.IssueFrameID() + + assert.Equal(t, uint32(1), id1) + assert.Equal(t, uint32(2), id2) + assert.Equal(t, uint32(3), id3) + + assert.Equal(t, []uint32{0, 1}, path1) + assert.Equal(t, []uint32{0, 2}, path2) + assert.Equal(t, []uint32{0, 3}, path3) + + // Next real call should continue from 4 + frameID, path := ct.ProcessDepthChange(2) + assert.Equal(t, uint32(4), frameID) + assert.Equal(t, []uint32{0, 4}, path) +} + +func TestIsPrecompile(t *testing.T) { + tests := []struct { + name string + addr string + expected bool + }{ + // Known precompiles (should return true) + {"ecrecover", "0x0000000000000000000000000000000000000001", true}, + {"sha256", "0x0000000000000000000000000000000000000002", true}, + {"ripemd160", "0x0000000000000000000000000000000000000003", true}, + {"identity", "0x0000000000000000000000000000000000000004", true}, + {"modexp", "0x0000000000000000000000000000000000000005", true}, + {"bn256Add", "0x0000000000000000000000000000000000000006", true}, + {"bn256ScalarMul", "0x0000000000000000000000000000000000000007", true}, + {"bn256Pairing", "0x0000000000000000000000000000000000000008", true}, + {"blake2f", "0x0000000000000000000000000000000000000009", true}, + {"kzgPointEvaluation", "0x000000000000000000000000000000000000000a", true}, + {"bls12381G1Add", "0x000000000000000000000000000000000000000b", true}, + {"bls12381G1Msm", "0x000000000000000000000000000000000000000c", true}, + {"bls12381G2Add", "0x000000000000000000000000000000000000000d", true}, + {"bls12381G2Msm", "0x000000000000000000000000000000000000000e", true}, + {"bls12381PairingCheck", "0x000000000000000000000000000000000000000f", true}, + {"bls12381MapFpToG1", "0x0000000000000000000000000000000000000010", true}, + {"bls12381MapFp2ToG2", "0x0000000000000000000000000000000000000011", true}, + {"p256Verify", "0x0000000000000000000000000000000000000100", true}, + + // Low addresses that are NOT precompiles (should return false) + // These are real EOAs/contracts deployed early in Ethereum's history + {"zero address", "0x0000000000000000000000000000000000000000", false}, + {"address 0x5c", "0x000000000000000000000000000000000000005c", false}, + {"address 0x60", "0x0000000000000000000000000000000000000060", false}, + {"address 0x44", "0x0000000000000000000000000000000000000044", false}, + {"address 0x348", "0x0000000000000000000000000000000000000348", false}, + {"address 0xffff", "0x000000000000000000000000000000000000ffff", false}, + {"address 0x12", "0x0000000000000000000000000000000000000012", false}, // Just above 0x11 + + // Real contract/EOA addresses (should return false) + {"WETH", "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", false}, + {"Uniswap V2 Router", "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", false}, + {"vitalik.eth", "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", false}, + {"random EOA", "0x1234567890abcdef1234567890abcdef12345678", false}, + + // Case insensitivity for precompiles + {"uppercase hex 0xA", "0x000000000000000000000000000000000000000A", true}, + {"mixed case 0xB", "0x000000000000000000000000000000000000000B", true}, + {"uppercase contract", "0xC02AAA39B223FE8D0A0E5C4F27EAD9083C756CC2", false}, + + // Without 0x prefix (edge case) + {"no prefix precompile", "0000000000000000000000000000000000000001", true}, + {"no prefix contract", "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", false}, + + // Short addresses (should be padded) + {"short precompile 0x1", "0x1", true}, + {"short precompile 0x9", "0x9", true}, + {"short precompile 0x100", "0x100", true}, + {"short non-precompile 0x5c", "0x5c", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := isPrecompile(tc.addr) + assert.Equal(t, tc.expected, result, "isPrecompile(%q) = %v, want %v", tc.addr, result, tc.expected) + }) + } +} + +// TestIsPrecompile_UsesGethDefinitions verifies that precompile detection +// uses go-ethereum's precompile definitions from vm.PrecompiledContractsOsaka. +func TestIsPrecompile_UsesGethDefinitions(t *testing.T) { + // Verify all go-ethereum precompiles are detected + for addr := range vm.PrecompiledContractsOsaka { + addrStr := addr.Hex() + assert.True(t, isPrecompile(addrStr), + "go-ethereum precompile %s should be detected as precompile", addrStr) + } + + // Verify the expected count matches go-ethereum + // As of Osaka: 0x01-0x11 (17) + 0x100 (1) = 18 precompiles + assert.Equal(t, 18, len(vm.PrecompiledContractsOsaka), + "expected 18 precompiles in go-ethereum Osaka fork") +} + +func TestIsCallOpcode(t *testing.T) { + tests := []struct { + opcode string + expected bool + }{ + // CALL-type opcodes (should return true) + {"CALL", true}, + {"CALLCODE", true}, + {"DELEGATECALL", true}, + {"STATICCALL", true}, + + // CREATE opcodes (should return false - they always increase depth) + {"CREATE", false}, + {"CREATE2", false}, + + // Other opcodes (should return false) + {"ADD", false}, + {"SUB", false}, + {"SLOAD", false}, + {"SSTORE", false}, + {"PUSH1", false}, + {"POP", false}, + {"JUMP", false}, + {"RETURN", false}, + {"REVERT", false}, + {"STOP", false}, + {"", false}, + } + + for _, tc := range tests { + t.Run(tc.opcode, func(t *testing.T) { + result := isCallOpcode(tc.opcode) + assert.Equal(t, tc.expected, result, "isCallOpcode(%q) = %v, want %v", tc.opcode, result, tc.expected) + }) + } +} + +// TestEOADetectionLogic tests the EOA call detection scenarios. +// This validates the fix for the bug where synthetic frames were incorrectly +// created for failed calls (depth decrease) instead of only for EOA calls (depth same). +func TestEOADetectionLogic(t *testing.T) { + // Helper to simulate the EOA detection logic from transaction_processing.go + shouldCreateSyntheticFrame := func(currentDepth, nextDepth uint64, hasNextOpcode bool, callToAddr string) bool { + if !hasNextOpcode { + // Last opcode is a CALL - we can't determine if it's EOA + // because we don't have a next opcode to compare depth with. + return false + } + + // Only create synthetic frame if depth stayed the same (EOA call) + // Depth increase = entered contract code (not EOA) + // Depth decrease = call returned/failed (not EOA) + // Depth same = called EOA or precompile (immediate return) + if nextDepth == currentDepth && !isPrecompile(callToAddr) { + return true + } + + return false + } + + tests := []struct { + name string + currentDepth uint64 + nextDepth uint64 + hasNextOp bool + callToAddr string + expectSynth bool + description string + }{ + // EOA call scenarios (should create synthetic frame) + { + name: "EOA call - depth stays same", + currentDepth: 2, + nextDepth: 2, + hasNextOp: true, + callToAddr: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", // vitalik.eth + expectSynth: true, + description: "CALL to EOA returns immediately, depth stays same", + }, + { + name: "EOA call from root depth", + currentDepth: 1, + nextDepth: 1, + hasNextOp: true, + callToAddr: "0x1234567890abcdef1234567890abcdef12345678", + expectSynth: true, + description: "CALL to EOA from root frame", + }, + + // Precompile call scenarios (should NOT create synthetic frame) + { + name: "precompile call - ecrecover", + currentDepth: 2, + nextDepth: 2, + hasNextOp: true, + callToAddr: "0x0000000000000000000000000000000000000001", + expectSynth: false, + description: "CALL to ecrecover precompile", + }, + { + name: "precompile call - sha256", + currentDepth: 2, + nextDepth: 2, + hasNextOp: true, + callToAddr: "0x0000000000000000000000000000000000000002", + expectSynth: false, + description: "CALL to sha256 precompile", + }, + { + name: "precompile call - kzg point eval", + currentDepth: 3, + nextDepth: 3, + hasNextOp: true, + callToAddr: "0x000000000000000000000000000000000000000a", + expectSynth: false, + description: "STATICCALL to KZG point evaluation precompile", + }, + + // Contract call scenarios (should NOT create synthetic frame) + { + name: "contract call - depth increases", + currentDepth: 2, + nextDepth: 3, + hasNextOp: true, + callToAddr: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // WETH + expectSynth: false, + description: "CALL to contract enters code, depth increases", + }, + { + name: "delegatecall - depth increases", + currentDepth: 1, + nextDepth: 2, + hasNextOp: true, + callToAddr: "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // Uniswap Router + expectSynth: false, + description: "DELEGATECALL enters implementation code", + }, + { + name: "nested contract call", + currentDepth: 3, + nextDepth: 4, + hasNextOp: true, + callToAddr: "0xabcdef1234567890abcdef1234567890abcdef12", + expectSynth: false, + description: "Nested CALL enters deeper contract", + }, + + // Failed/returning call scenarios (should NOT create synthetic frame) + // This is the bug we fixed - depth DECREASE was incorrectly treated as EOA + { + name: "failed call - depth decreases by 1", + currentDepth: 3, + nextDepth: 2, + hasNextOp: true, + callToAddr: "0xde9c774cde34f85ee69c22e9a1077a0c9091f09b", + expectSynth: false, + description: "CALL failed/reverted, returned to caller depth", + }, + { + name: "failed call - depth decreases by 2", + currentDepth: 4, + nextDepth: 2, + hasNextOp: true, + callToAddr: "0xabcdef1234567890abcdef1234567890abcdef12", + expectSynth: false, + description: "CALL caused revert unwinding multiple frames", + }, + { + name: "out of gas - depth returns to root", + currentDepth: 3, + nextDepth: 1, + hasNextOp: true, + callToAddr: "0xfe02a32cbe0cb9ad9a945576a5bb53a3c123a3a3", + expectSynth: false, + description: "Out of gas unwinds all the way to root", + }, + { + name: "call returns normally", + currentDepth: 2, + nextDepth: 1, + hasNextOp: true, + callToAddr: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + expectSynth: false, + description: "Contract call completed and returned", + }, + + // Last opcode scenarios (should NOT create synthetic frame) + { + name: "last opcode is CALL - no next opcode", + currentDepth: 2, + nextDepth: 0, // doesn't matter + hasNextOp: false, + callToAddr: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + expectSynth: false, + description: "Transaction ends with CALL (likely failed)", + }, + { + name: "last opcode CALL to contract", + currentDepth: 1, + nextDepth: 0, + hasNextOp: false, + callToAddr: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + expectSynth: false, + description: "Can't determine if EOA without next opcode", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := shouldCreateSyntheticFrame(tc.currentDepth, tc.nextDepth, tc.hasNextOp, tc.callToAddr) + assert.Equal(t, tc.expectSynth, result, + "%s: shouldCreateSyntheticFrame(depth=%d, nextDepth=%d, hasNext=%v, addr=%s) = %v, want %v", + tc.description, tc.currentDepth, tc.nextDepth, tc.hasNextOp, tc.callToAddr, result, tc.expectSynth) + }) + } +} + +// TestEOADetectionBugScenario_DepthDecrease verifies the fix for the bug where +// a CALL followed by depth decrease was incorrectly treated as an EOA call. +// Real-world example: transaction 0x4f7494... had a CALL at depth 3, next opcode +// was at depth 2 (returned/failed). The old <= check created a phantom synthetic frame. +func TestEOADetectionBugScenario_DepthDecrease(t *testing.T) { + // Simulate the buggy scenario from tx 0x4f7494c9f3b1bb7fb9f4d928aae41d971f0799a3d5c24df209074b70f04211f5 + // Index 235: GAS at depth 3 + // Index 236: CALL at depth 3 (to 0xde9c774cde34f85ee69c22e9a1077a0c9091f09b) + // Index 237: RETURNDATASIZE at depth 2 (call returned/failed) + currentDepth := uint64(3) + nextDepth := uint64(2) // Depth DECREASED (call returned/failed) + callToAddr := "0xde9c774cde34f85ee69c22e9a1077a0c9091f09b" + + // Old buggy logic: nextDepth <= currentDepth → 2 <= 3 → TRUE (wrong!) + buggyLogic := nextDepth <= currentDepth && !isPrecompile(callToAddr) + assert.True(t, buggyLogic, "Old buggy logic would have created synthetic frame") + + // Fixed logic: nextDepth == currentDepth → 2 == 3 → FALSE (correct!) + fixedLogic := nextDepth == currentDepth && !isPrecompile(callToAddr) + assert.False(t, fixedLogic, "Fixed logic should NOT create synthetic frame") +} + +// TestEOADetectionBugScenario_OutOfGas verifies the fix for the bug where +// a CALL as the last opcode (out of gas) was incorrectly treated as an EOA call. +// Real-world example: transaction 0x7178d8e3... ended with a CALL that ran out of gas. +func TestEOADetectionBugScenario_OutOfGas(t *testing.T) { + // Simulate the buggy scenario from tx 0x7178d8e3a33331ee0b2c42372c357cb6135bf3acd6e1eea5dbca7d9dbedfa418 + // Index 10: GAS at depth 1 + // Index 11: CALL at depth 1 (last opcode - out of gas before entering target) + // No index 12 (trace ended) + callToAddr := "0xfe02a32cbe0cb9ad9a945576a5bb53a3c123a3a3" + hasNextOpcode := false + + // Old buggy logic: "Last opcode is a CALL - if not precompile, must be EOA" + buggyLogic := !hasNextOpcode && !isPrecompile(callToAddr) + assert.True(t, buggyLogic, "Old buggy logic would have created synthetic frame") + + // Fixed logic: Don't assume last CALL is EOA - we can't determine without next opcode + fixedLogic := hasNextOpcode && !isPrecompile(callToAddr) // Always false when !hasNextOpcode + assert.False(t, fixedLogic, "Fixed logic should NOT create synthetic frame for last opcode") +} + +func TestCallTracker_RealWorldExample(t *testing.T) { + // Simulate a real EVM trace where depth starts at 1: + // op=PUSH1, depth=1 → frame_id=0, path=[0] (root execution) + // op=CALL(A),depth=1 → frame_id=0, path=[0] + // op=ADD, depth=2 → frame_id=1, path=[0,1] (inside A) + // op=CALL(B),d=2 → frame_id=1, path=[0,1] + // op=MUL, d=3 → frame_id=2, path=[0,1,2] (inside B) + // op=CALL(C),d=3 → frame_id=2, path=[0,1,2] + // op=SLOAD,d=4 → frame_id=3, path=[0,1,2,3] (inside C) + // op=RETURN,d=4 → frame_id=3, path=[0,1,2,3] + // op=ADD, d=3 → frame_id=2, path=[0,1,2] (back in B) + // op=RETURN,d=3 → frame_id=2, path=[0,1,2] + // op=POP, depth=2 → frame_id=1, path=[0,1] (back in A) + // op=STOP, depth=1 → frame_id=0, path=[0] (back in root) + ct := NewCallTracker() + + type expected struct { + depth uint64 + frameID uint32 + path []uint32 + } + + testCases := []expected{ + {1, 0, []uint32{0}}, // PUSH1 (root) + {1, 0, []uint32{0}}, // CALL(A) + {2, 1, []uint32{0, 1}}, // ADD (inside A) + {2, 1, []uint32{0, 1}}, // CALL(B) + {3, 2, []uint32{0, 1, 2}}, // MUL (inside B) + {3, 2, []uint32{0, 1, 2}}, // CALL(C) + {4, 3, []uint32{0, 1, 2, 3}}, // SLOAD (inside C) + {4, 3, []uint32{0, 1, 2, 3}}, // RETURN (inside C) + {3, 2, []uint32{0, 1, 2}}, // ADD (back in B) + {3, 2, []uint32{0, 1, 2}}, // RETURN (inside B) + {2, 1, []uint32{0, 1}}, // POP (back in A) + {1, 0, []uint32{0}}, // STOP (back in root) + } + + for i, tc := range testCases { + frameID, path := ct.ProcessDepthChange(tc.depth) + assert.Equal(t, tc.frameID, frameID, "case %d: frame_id mismatch", i) + assert.Equal(t, tc.path, path, "case %d: path mismatch", i) + } +} diff --git a/pkg/processor/transaction/structlog/create_address_test.go b/pkg/processor/transaction/structlog/create_address_test.go new file mode 100644 index 0000000..b77f466 --- /dev/null +++ b/pkg/processor/transaction/structlog/create_address_test.go @@ -0,0 +1,262 @@ +package structlog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" +) + +const testCreateAddress = "0x1234567890abcdef1234567890abcdef12345678" + +func TestComputeCreateAddresses_Empty(t *testing.T) { + result := ComputeCreateAddresses([]execution.StructLog{}) + assert.Empty(t, result) +} + +func TestComputeCreateAddresses_NoCREATE(t *testing.T) { + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1}, + {Op: "CALL", Depth: 1}, + {Op: "ADD", Depth: 2}, + {Op: "RETURN", Depth: 2}, + {Op: "STOP", Depth: 1}, + } + + result := ComputeCreateAddresses(structlogs) + assert.Empty(t, result) +} + +func TestComputeCreateAddresses_SingleCREATE(t *testing.T) { + // Simulate: CREATE at depth 2, constructor runs at depth 3, returns + createdAddr := "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + stack := []string{createdAddr} + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 2}, + {Op: "CREATE", Depth: 2}, // index 1 + {Op: "PUSH1", Depth: 3}, // constructor starts + {Op: "RETURN", Depth: 3}, // constructor ends + {Op: "SWAP1", Depth: 2, Stack: &stack}, // back in caller, stack has address + } + + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + // Address is already 40 chars, so stays the same + assert.Equal(t, createdAddr, *result[1]) +} + +func TestComputeCreateAddresses_CREATE2(t *testing.T) { + createdAddr := "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + stack := []string{createdAddr} + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1}, + {Op: "CREATE2", Depth: 1}, // index 1 + {Op: "ADD", Depth: 2}, // constructor + {Op: "RETURN", Depth: 2}, // constructor ends + {Op: "POP", Depth: 1, Stack: &stack}, // back in caller + } + + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + assert.Equal(t, createdAddr, *result[1]) +} + +func TestComputeCreateAddresses_FailedCREATE(t *testing.T) { + // When CREATE fails immediately, next opcode is at same depth with 0 on stack + zeroAddr := "0x0" + stack := []string{zeroAddr} + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 2}, + {Op: "CREATE", Depth: 2}, // index 1 - fails immediately + {Op: "ISZERO", Depth: 2, Stack: &stack}, // still at depth 2, stack has 0 + } + + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + // Zero address is zero-padded to 40 hex chars + assert.Equal(t, "0x0000000000000000000000000000000000000000", *result[1]) +} + +func TestComputeCreateAddresses_NestedCREATEs(t *testing.T) { + // Outer CREATE at depth 1, inner CREATE at depth 2 + innerAddr := "0x1111111111111111111111111111111111111111" + outerAddr := "0x2222222222222222222222222222222222222222" + innerStack := []string{innerAddr} + outerStack := []string{outerAddr} + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1}, + {Op: "CREATE", Depth: 1}, // index 1 - outer CREATE + {Op: "PUSH1", Depth: 2}, // outer constructor starts + {Op: "CREATE", Depth: 2}, // index 3 - inner CREATE + {Op: "ADD", Depth: 3}, // inner constructor + {Op: "RETURN", Depth: 3}, // inner constructor ends + {Op: "POP", Depth: 2, Stack: &innerStack}, // back in outer constructor + {Op: "RETURN", Depth: 2}, // outer constructor ends + {Op: "SWAP1", Depth: 1, Stack: &outerStack}, // back in original caller + } + + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + require.Contains(t, result, 3) + assert.Equal(t, outerAddr, *result[1]) + assert.Equal(t, innerAddr, *result[3]) +} + +func TestComputeCreateAddresses_MultipleCREATEsSameDepth(t *testing.T) { + // Two CREATEs at the same depth (sequential, not nested) + addr1 := "0x1111111111111111111111111111111111111111" + addr2 := "0x2222222222222222222222222222222222222222" + stack1 := []string{addr1} + stack2 := []string{addr2} + + structlogs := []execution.StructLog{ + {Op: "PUSH1", Depth: 1}, + {Op: "CREATE", Depth: 1}, // index 1 - first CREATE + {Op: "ADD", Depth: 2}, // first constructor + {Op: "RETURN", Depth: 2}, // first constructor ends + {Op: "POP", Depth: 1, Stack: &stack1}, // back, has first address + {Op: "PUSH1", Depth: 1}, + {Op: "CREATE", Depth: 1}, // index 6 - second CREATE + {Op: "MUL", Depth: 2}, // second constructor + {Op: "RETURN", Depth: 2}, // second constructor ends + {Op: "SWAP1", Depth: 1, Stack: &stack2}, // back, has second address + } + + result := ComputeCreateAddresses(structlogs) + + require.Contains(t, result, 1) + require.Contains(t, result, 6) + assert.Equal(t, addr1, *result[1]) + assert.Equal(t, addr2, *result[6]) +} + +func TestExtractCallAddressWithCreate_CREATE(t *testing.T) { + p := &Processor{} + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE", + }, 0, createAddresses) + + assert.NotNil(t, result) + assert.Equal(t, testCreateAddress, *result) +} + +func TestExtractCallAddressWithCreate_CREATE2(t *testing.T) { + p := &Processor{} + addr := "0xabcdef1234567890abcdef1234567890abcdef12" + createAddresses := map[int]*string{ + 5: ptrString(addr), + } + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE2", + }, 5, createAddresses) + + assert.NotNil(t, result) + assert.Equal(t, addr, *result) +} + +func TestExtractCallAddressWithCreate_CREATEWithNilMap(t *testing.T) { + p := &Processor{} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE", + }, 0, nil) + + assert.Nil(t, result) +} + +func TestExtractCallAddressWithCreate_CREATENotInMap(t *testing.T) { + p := &Processor{} + createAddresses := map[int]*string{ + 10: ptrString(testCreateAddress), + } + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CREATE", + }, 5, createAddresses) // index 5 not in map + + assert.Nil(t, result) +} + +func TestExtractCallAddressWithCreate_CALLDelegatesToExtractCallAddress(t *testing.T) { + p := &Processor{} + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } + stack := []string{"0x5208", "0xdeadbeef"} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }, 0, createAddresses) + + // Should use extractCallAddress, not createAddresses + assert.NotNil(t, result) + // Second from top of stack, zero-padded to 40 hex chars + assert.Equal(t, "0x0000000000000000000000000000000000005208", *result) +} + +func TestExtractCallAddressWithCreate_DELEGATECALLDelegatesToExtractCallAddress(t *testing.T) { + p := &Processor{} + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } + stack := []string{"0x5208", "0xdeadbeef"} + + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: "DELEGATECALL", + Stack: &stack, + }, 0, createAddresses) + + assert.NotNil(t, result) + // Zero-padded to 40 hex chars + assert.Equal(t, "0x0000000000000000000000000000000000005208", *result) +} + +func TestExtractCallAddressWithCreate_NonCallOpcodeReturnsNil(t *testing.T) { + p := &Processor{} + createAddresses := map[int]*string{ + 0: ptrString(testCreateAddress), + } + stack := []string{"0x5208", "0xdeadbeef"} + + testCases := []string{ + "PUSH1", + "ADD", + "SLOAD", + "SSTORE", + "RETURN", + "REVERT", + "STOP", + } + + for _, op := range testCases { + t.Run(op, func(t *testing.T) { + result := p.extractCallAddressWithCreate(&execution.StructLog{ + Op: op, + Stack: &stack, + }, 0, createAddresses) + + assert.Nil(t, result, "opcode %s should return nil", op) + }) + } +} + +// ptrString returns a pointer to the given string. +func ptrString(s string) *string { + return &s +} diff --git a/pkg/processor/transaction/structlog/extract_call_address_test.go b/pkg/processor/transaction/structlog/extract_call_address_test.go new file mode 100644 index 0000000..d72cb7f --- /dev/null +++ b/pkg/processor/transaction/structlog/extract_call_address_test.go @@ -0,0 +1,270 @@ +package structlog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" +) + +func TestExtractCallAddress_NilStack(t *testing.T) { + p := &Processor{} + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: nil, + }) + + assert.Nil(t, result) +} + +func TestExtractCallAddress_EmptyStack(t *testing.T) { + p := &Processor{} + emptyStack := []string{} + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &emptyStack, + }) + + assert.Nil(t, result) +} + +func TestExtractCallAddress_InsufficientStack(t *testing.T) { + p := &Processor{} + stack := []string{"0x1234"} // Only 1 element, need at least 2 + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.Nil(t, result) +} + +func TestExtractCallAddress_CALL(t *testing.T) { + p := &Processor{} + // CALL stack (index 0 = bottom, len-1 = top): + // [retSize, retOffset, argsSize, argsOffset, value, addr, gas] + // Address is at index len-2 (second from top) + stack := []string{ + "0x0", // retSize (bottom, index 0) + "0x0", // retOffset + "0x0", // argsSize + "0x0", // argsOffset + "0x0", // value + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr (index len-2) + "0x5208", // gas (top, index len-1) + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_CALL_MinimalStack(t *testing.T) { + p := &Processor{} + // Minimal stack with just 2 elements (addr at index 0, gas at index 1) + stack := []string{ + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr (index 0 = len-2) + "0x5208", // gas (index 1 = len-1) + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_CALL_WithExtraStackItemsBelow(t *testing.T) { + p := &Processor{} + // Stack with extra items BELOW CALL args (at the bottom) + // The CALL args are still at the top, so len-2 still gives addr + stack := []string{ + "0xdeadbeef", // extra item (bottom) + "0xcafebabe", // another extra item + "0x0", // retSize (start of CALL args) + "0x0", // retOffset + "0x0", // argsSize + "0x0", // argsOffset + "0x0", // value + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr (len-2) + "0x5208", // gas (top, len-1) + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_CALLCODE(t *testing.T) { + p := &Processor{} + // CALLCODE has same stack layout as CALL + stack := []string{ + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + "0x5208", // gas + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALLCODE", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_DELEGATECALL(t *testing.T) { + p := &Processor{} + // DELEGATECALL stack (no value parameter, but addr still at len-2): + // [retSize, retOffset, argsSize, argsOffset, addr, gas] + stack := []string{ + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + "0x5208", // gas + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "DELEGATECALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_STATICCALL(t *testing.T) { + p := &Processor{} + // STATICCALL has same stack layout as DELEGATECALL + stack := []string{ + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", // addr + "0x5208", // gas + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "STATICCALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", *result) +} + +func TestExtractCallAddress_NonCallOpcode(t *testing.T) { + p := &Processor{} + stack := []string{"0x1234", "0x5678"} + + testCases := []string{ + "PUSH1", + "ADD", + "SLOAD", + "SSTORE", + "JUMP", + "RETURN", + "REVERT", + "CREATE", // CREATE is not handled (address comes from trace) + "CREATE2", // CREATE2 is not handled (address comes from trace) + } + + for _, op := range testCases { + t.Run(op, func(t *testing.T) { + result := p.extractCallAddress(&execution.StructLog{ + Op: op, + Stack: &stack, + }) + assert.Nil(t, result, "opcode %s should not extract call address", op) + }) + } +} + +func TestExtractCallAddress_ShortAddressPadding(t *testing.T) { + p := &Processor{} + // Test that short addresses (like precompiles) get zero-padded + stack := []string{ + "0x1", // addr - precompile ecRecover, should be padded + "0x5208", // gas + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x0000000000000000000000000000000000000001", *result) + assert.Len(t, *result, 42) +} + +func TestExtractCallAddress_Permit2Padding(t *testing.T) { + p := &Processor{} + // Test Permit2 address with leading zeros + stack := []string{ + "0x22d473030f116ddee9f6b43ac78ba3", // Permit2 truncated + "0x5208", // gas + } + + result := p.extractCallAddress(&execution.StructLog{ + Op: "CALL", + Stack: &stack, + }) + + assert.NotNil(t, result) + assert.Equal(t, "0x000000000022d473030f116ddee9f6b43ac78ba3", *result) + assert.Len(t, *result, 42) +} + +func TestExtractCallAddress_AllCallVariants(t *testing.T) { + // Table-driven test for all supported CALL variants + p := &Processor{} + + targetAddr := "0x7a250d5630b4cf539739df2c5dacb4c659f2488d" + + testCases := []struct { + name string + op string + stack []string // Stack with addr at len-2 and gas at len-1 + }{ + { + name: "CALL with full stack", + op: "CALL", + stack: []string{"0xretSize", "0xretOff", "0xargsSize", "0xargsOff", "0xvalue", targetAddr, "0xgas"}, + }, + { + name: "CALLCODE with full stack", + op: "CALLCODE", + stack: []string{"0xretSize", "0xretOff", "0xargsSize", "0xargsOff", "0xvalue", targetAddr, "0xgas"}, + }, + { + name: "DELEGATECALL with full stack", + op: "DELEGATECALL", + stack: []string{"0xretSize", "0xretOff", "0xargsSize", "0xargsOff", targetAddr, "0xgas"}, + }, + { + name: "STATICCALL with full stack", + op: "STATICCALL", + stack: []string{"0xretSize", "0xretOff", "0xargsSize", "0xargsOff", targetAddr, "0xgas"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := p.extractCallAddress(&execution.StructLog{ + Op: tc.op, + Stack: &tc.stack, + }) + assert.NotNil(t, result) + assert.Equal(t, targetAddr, *result) + }) + } +} diff --git a/pkg/processor/transaction/structlog/format_address_test.go b/pkg/processor/transaction/structlog/format_address_test.go new file mode 100644 index 0000000..7b26b62 --- /dev/null +++ b/pkg/processor/transaction/structlog/format_address_test.go @@ -0,0 +1,115 @@ +package structlog + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatAddress(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "already 40 chars with 0x prefix", + input: "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + expected: "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + }, + { + name: "already 40 chars without 0x prefix", + input: "7a250d5630b4cf539739df2c5dacb4c659f2488d", + expected: "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + }, + { + name: "precompile address 0x1", + input: "0x1", + expected: "0x0000000000000000000000000000000000000001", + }, + { + name: "precompile address 0xa", + input: "0xa", + expected: "0x000000000000000000000000000000000000000a", + }, + { + name: "Permit2 with leading zeros truncated", + input: "0x22d473030f116ddee9f6b43ac78ba3", + expected: "0x000000000022d473030f116ddee9f6b43ac78ba3", + }, + { + name: "Uniswap PoolManager with leading zeros truncated", + input: "0x4444c5dc75cb358380d2e3de08a90", + expected: "0x000000000004444c5dc75cb358380d2e3de08a90", + }, + { + name: "zero address", + input: "0x0", + expected: "0x0000000000000000000000000000000000000000", + }, + { + name: "short address without 0x prefix", + input: "5208", + expected: "0x0000000000000000000000000000000000005208", + }, + { + name: "short address with 0x prefix", + input: "0x5208", + expected: "0x0000000000000000000000000000000000005208", + }, + { + name: "empty string", + input: "", + expected: "0x0000000000000000000000000000000000000000", + }, + { + name: "just 0x prefix", + input: "0x", + expected: "0x0000000000000000000000000000000000000000", + }, + // Full 32-byte stack values (66 chars) - extract lower 20 bytes + { + name: "full 32-byte stack value from XEN Batch Minter", + input: "0x661f30bf3a790c8687131ae8fc6e649df9f27275fc286db8f1a0be7e99b24bb2", + expected: "0xfc6e649df9f27275fc286db8f1a0be7e99b24bb2", + }, + { + name: "full 32-byte stack value - all zeros except address", + input: "0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d", + expected: "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + }, + { + name: "full 32-byte stack value without 0x prefix", + input: "661f30bf3a790c8687131ae8fc6e649df9f27275fc286db8f1a0be7e99b24bb2", + expected: "0xfc6e649df9f27275fc286db8f1a0be7e99b24bb2", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := formatAddress(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestFormatAddress_LengthConsistency(t *testing.T) { + // All formatted addresses should be exactly 42 characters (0x + 40 hex chars) + inputs := []string{ + "0x1", + "0xa", + "0xdeadbeef", + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + "1", + "abcdef", + "", + } + + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + result := formatAddress(input) + assert.Len(t, result, 42, "formatted address should always be 42 chars") + assert.Equal(t, "0x", result[:2], "formatted address should start with 0x") + }) + } +} diff --git a/pkg/processor/transaction/structlog/gas_cost.go b/pkg/processor/transaction/structlog/gas_cost.go index aa5c0fd..d32572e 100644 --- a/pkg/processor/transaction/structlog/gas_cost.go +++ b/pkg/processor/transaction/structlog/gas_cost.go @@ -6,6 +6,49 @@ import ( "github.com/ethpandaops/execution-processor/pkg/ethereum/execution" ) +// ============================================================================= +// GAS FIELDS +// ============================================================================= +// +// The structlog contains three gas-related fields: +// +// GasCost +// Source: Directly from geth/erigon debug_traceTransaction response. +// For non-CALL opcodes: The static cost charged for the opcode. +// For CALL/CREATE opcodes: The gas stipend passed to the child frame. +// +// GasUsed +// Source: Computed as gas[i] - gas[i+1] for consecutive opcodes at same depth. +// For non-CALL opcodes: Actual gas consumed by the opcode. +// For CALL/CREATE opcodes: Includes the call overhead PLUS all child frame gas. +// Note: Summing gas_used across all opcodes double counts because CALL's +// gas_used includes child gas, and children also report their own gas_used. +// +// GasSelf +// Source: Computed as gas_used minus the sum of all child frame gas_used. +// For non-CALL opcodes: Equal to gas_used. +// For CALL/CREATE opcodes: Only the call overhead (warm/cold access, memory +// expansion, value transfer) without child frame gas. +// Summing gas_self across all opcodes gives total execution gas without +// double counting. +// +// Example for a CALL opcode: +// gas_cost = 7,351,321 (stipend passed to child) +// gas_used = 23,858 (overhead 2,600 + child consumed 21,258) +// gas_self = 2,600 (just the CALL overhead) +// +// ============================================================================= + +// Opcode constants for call and create operations. +const ( + OpcodeCALL = "CALL" + OpcodeCALLCODE = "CALLCODE" + OpcodeDELEGATECALL = "DELEGATECALL" + OpcodeSTATICCALL = "STATICCALL" + OpcodeCREATE = "CREATE" + OpcodeCREATE2 = "CREATE2" +) + // ComputeGasUsed calculates the actual gas consumed for each structlog using // the difference between consecutive gas values at the same depth level. // @@ -62,3 +105,65 @@ func ComputeGasUsed(structlogs []execution.StructLog) []uint64 { return gasUsed } + +// ComputeGasSelf calculates the gas consumed by each opcode excluding child frame gas. +// For CALL/CREATE opcodes, this represents only the call overhead (warm/cold access, +// memory expansion, value transfer), not the gas consumed by child frames. +// For all other opcodes, this equals gasUsed. +// +// This is useful for gas analysis where you want to sum gas without double counting: +// sum(gasSelf) = total transaction execution gas (no double counting). +func ComputeGasSelf(structlogs []execution.StructLog, gasUsed []uint64) []uint64 { + if len(structlogs) == 0 { + return nil + } + + gasSelf := make([]uint64, len(structlogs)) + copy(gasSelf, gasUsed) + + for i := range structlogs { + op := structlogs[i].Op + if !isCallOrCreateOpcode(op) { + continue + } + + callDepth := structlogs[i].Depth + + var childGasSum uint64 + + // Sum gas_used for DIRECT children only (depth == callDepth + 1). + // We only sum direct children because their gas_used already includes + // any nested descendants. Summing all descendants would double count. + for j := i + 1; j < len(structlogs); j++ { + if structlogs[j].Depth <= callDepth { + break + } + + if structlogs[j].Depth == callDepth+1 { + childGasSum += gasUsed[j] + } + } + + // gasSelf = total gas attributed to this CALL minus child execution + // This gives us just the CALL overhead + if gasUsed[i] >= childGasSum { + gasSelf[i] = gasUsed[i] - childGasSum + } else { + // Edge case: if child gas exceeds parent (shouldn't happen in valid traces) + // fall back to 0 to avoid underflow + gasSelf[i] = 0 + } + } + + return gasSelf +} + +// isCallOrCreateOpcode returns true if the opcode spawns a new call frame. +func isCallOrCreateOpcode(op string) bool { + switch op { + case OpcodeCALL, OpcodeCALLCODE, OpcodeDELEGATECALL, OpcodeSTATICCALL, OpcodeCREATE, OpcodeCREATE2: + return true + default: + return false + } +} diff --git a/pkg/processor/transaction/structlog/gas_cost_test.go b/pkg/processor/transaction/structlog/gas_cost_test.go index 868d690..cf85c08 100644 --- a/pkg/processor/transaction/structlog/gas_cost_test.go +++ b/pkg/processor/transaction/structlog/gas_cost_test.go @@ -313,3 +313,286 @@ func TestComputeGasUsed_LargeDepth(t *testing.T) { assert.Equal(t, uint64(2), result[8]) assert.Equal(t, uint64(2), result[9]) } + +// ============================================================================= +// ComputeGasSelf Tests +// ============================================================================= + +func TestComputeGasSelf_EmptyLogs(t *testing.T) { + result := ComputeGasSelf(nil, nil) + assert.Nil(t, result) + + result = ComputeGasSelf([]execution.StructLog{}, []uint64{}) + assert.Nil(t, result) +} + +func TestComputeGasSelf_NonCallOpcodes(t *testing.T) { + // For non-CALL opcodes, gas_self should equal gas_used + structlogs := []execution.StructLog{ + {Op: "PUSH1", Gas: 100000, GasCost: 3, Depth: 1}, + {Op: "SLOAD", Gas: 99997, GasCost: 2100, Depth: 1}, + {Op: "ADD", Gas: 97897, GasCost: 3, Depth: 1}, + } + + gasUsed := []uint64{3, 2100, 3} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 3) + assert.Equal(t, uint64(3), result[0], "PUSH1 gas_self should equal gas_used") + assert.Equal(t, uint64(2100), result[1], "SLOAD gas_self should equal gas_used") + assert.Equal(t, uint64(3), result[2], "ADD gas_self should equal gas_used") +} + +func TestComputeGasSelf_SimpleCall(t *testing.T) { + // CALL at depth 1 with child opcodes at depth 2 + // gas_self for CALL should be gas_used minus sum of direct children's gas_used + structlogs := []execution.StructLog{ + {Op: "PUSH1", Gas: 100000, GasCost: 3, Depth: 1}, // index 0 + {Op: "CALL", Gas: 99997, GasCost: 100, Depth: 1}, // index 1: CALL + {Op: "PUSH1", Gas: 63000, GasCost: 3, Depth: 2}, // index 2: child + {Op: "ADD", Gas: 62000, GasCost: 3, Depth: 2}, // index 3: child + {Op: "STOP", Gas: 61000, GasCost: 0, Depth: 2}, // index 4: child + {Op: "POP", Gas: 97000, GasCost: 2, Depth: 1}, // index 5: back to parent + } + + // gas_used values (computed by ComputeGasUsed logic): + // PUSH1[0]: 100000 - 99997 = 3 + // CALL[1]: 99997 - 97000 = 2997 (includes child execution) + // PUSH1[2]: 63000 - 62000 = 1000 + // ADD[3]: 62000 - 61000 = 1000 + // STOP[4]: 0 (pre-calculated, last at depth 2) + // POP[5]: 2 (pre-calculated, last opcode) + gasUsed := []uint64{3, 2997, 1000, 1000, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 6) + + // Non-CALL opcodes: gas_self == gas_used + assert.Equal(t, uint64(3), result[0], "PUSH1 gas_self") + assert.Equal(t, uint64(1000), result[2], "child PUSH1 gas_self") + assert.Equal(t, uint64(1000), result[3], "child ADD gas_self") + assert.Equal(t, uint64(0), result[4], "child STOP gas_self") + assert.Equal(t, uint64(2), result[5], "POP gas_self") + + // CALL: gas_self = gas_used - sum(direct children) + // direct children at depth 2: indices 2, 3, 4 + // sum = 1000 + 1000 + 0 = 2000 + // gas_self = 2997 - 2000 = 997 + assert.Equal(t, uint64(997), result[1], "CALL gas_self should be overhead only") +} + +func TestComputeGasSelf_NestedCalls(t *testing.T) { + // This is the critical test: nested CALLs where we must only sum direct children. + // If we sum ALL descendants, we double count and get incorrect (often 0) values. + // + // Structure: + // CALL A (depth 1) -> child frame at depth 2 + // ├─ PUSH (depth 2) + // ├─ CALL B (depth 2) -> grandchild frame at depth 3 + // │ ├─ ADD (depth 3) + // │ └─ STOP (depth 3) + // └─ STOP (depth 2) + structlogs := []execution.StructLog{ + {Op: "CALL", Gas: 100000, GasCost: 100, Depth: 1}, // index 0: CALL A + {Op: "PUSH1", Gas: 80000, GasCost: 3, Depth: 2}, // index 1: direct child of A + {Op: "CALL", Gas: 79000, GasCost: 100, Depth: 2}, // index 2: CALL B (direct child of A) + {Op: "ADD", Gas: 50000, GasCost: 3, Depth: 3}, // index 3: direct child of B + {Op: "STOP", Gas: 49000, GasCost: 0, Depth: 3}, // index 4: direct child of B + {Op: "STOP", Gas: 75000, GasCost: 0, Depth: 2}, // index 5: direct child of A + {Op: "POP", Gas: 90000, GasCost: 2, Depth: 1}, // index 6: back to depth 1 + } + + // gas_used values: + // CALL A[0]: 100000 - 90000 = 10000 (includes all nested) + // PUSH[1]: 80000 - 79000 = 1000 + // CALL B[2]: 79000 - 75000 = 4000 (includes grandchild) + // ADD[3]: 50000 - 49000 = 1000 + // STOP[4]: 0 (pre-calculated) + // STOP[5]: 0 (pre-calculated) + // POP[6]: 2 (pre-calculated) + gasUsed := []uint64{10000, 1000, 4000, 1000, 0, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 7) + + // CALL A: direct children at depth 2 are indices 1, 2, 5 + // sum of direct children = 1000 + 4000 + 0 = 5000 + // gas_self = 10000 - 5000 = 5000 + // Note: We do NOT include indices 3, 4 (depth 3) because they're grandchildren, + // and CALL B's gas_used (4000) already includes them. + assert.Equal(t, uint64(5000), result[0], "CALL A gas_self should exclude nested CALL's children") + + // CALL B: direct children at depth 3 are indices 3, 4 + // sum of direct children = 1000 + 0 = 1000 + // gas_self = 4000 - 1000 = 3000 + assert.Equal(t, uint64(3000), result[2], "CALL B gas_self should be its overhead") + + // Non-CALL opcodes: gas_self == gas_used + assert.Equal(t, uint64(1000), result[1], "PUSH gas_self") + assert.Equal(t, uint64(1000), result[3], "ADD gas_self") + assert.Equal(t, uint64(0), result[4], "STOP depth 3 gas_self") + assert.Equal(t, uint64(0), result[5], "STOP depth 2 gas_self") + assert.Equal(t, uint64(2), result[6], "POP gas_self") +} + +func TestComputeGasSelf_SiblingCalls(t *testing.T) { + // Two sibling CALLs at the same depth, each with their own children + structlogs := []execution.StructLog{ + {Op: "CALL", Gas: 100000, GasCost: 100, Depth: 1}, // index 0: first CALL + {Op: "ADD", Gas: 60000, GasCost: 3, Depth: 2}, // index 1: child of first CALL + {Op: "STOP", Gas: 59000, GasCost: 0, Depth: 2}, // index 2: child of first CALL + {Op: "CALL", Gas: 90000, GasCost: 100, Depth: 1}, // index 3: second CALL + {Op: "MUL", Gas: 50000, GasCost: 5, Depth: 2}, // index 4: child of second CALL + {Op: "STOP", Gas: 49000, GasCost: 0, Depth: 2}, // index 5: child of second CALL + {Op: "POP", Gas: 80000, GasCost: 2, Depth: 1}, // index 6 + } + + // gas_used: + // CALL[0]: 100000 - 90000 = 10000 + // ADD[1]: 60000 - 59000 = 1000 + // STOP[2]: 0 + // CALL[3]: 90000 - 80000 = 10000 + // MUL[4]: 50000 - 49000 = 1000 + // STOP[5]: 0 + // POP[6]: 2 + gasUsed := []uint64{10000, 1000, 0, 10000, 1000, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 7) + + // First CALL: direct children = indices 1, 2 + // gas_self = 10000 - (1000 + 0) = 9000 + assert.Equal(t, uint64(9000), result[0], "first CALL gas_self") + + // Second CALL: direct children = indices 4, 5 + // gas_self = 10000 - (1000 + 0) = 9000 + assert.Equal(t, uint64(9000), result[3], "second CALL gas_self") +} + +func TestComputeGasSelf_CreateOpcode(t *testing.T) { + // CREATE should be handled the same as CALL + structlogs := []execution.StructLog{ + {Op: "CREATE", Gas: 100000, GasCost: 32000, Depth: 1}, // index 0 + {Op: "PUSH1", Gas: 70000, GasCost: 3, Depth: 2}, // index 1: constructor + {Op: "RETURN", Gas: 69000, GasCost: 0, Depth: 2}, // index 2: constructor + {Op: "POP", Gas: 80000, GasCost: 2, Depth: 1}, // index 3 + } + + // gas_used: + // CREATE[0]: 100000 - 80000 = 20000 + // PUSH[1]: 70000 - 69000 = 1000 + // RETURN[2]: 0 + // POP[3]: 2 + gasUsed := []uint64{20000, 1000, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 4) + + // CREATE: direct children = indices 1, 2 + // gas_self = 20000 - (1000 + 0) = 19000 + assert.Equal(t, uint64(19000), result[0], "CREATE gas_self should be overhead only") + assert.Equal(t, uint64(1000), result[1], "PUSH gas_self") + assert.Equal(t, uint64(0), result[2], "RETURN gas_self") + assert.Equal(t, uint64(2), result[3], "POP gas_self") +} + +func TestComputeGasSelf_DelegateCallAndStaticCall(t *testing.T) { + // DELEGATECALL and STATICCALL should also be handled + structlogs := []execution.StructLog{ + {Op: "DELEGATECALL", Gas: 100000, GasCost: 100, Depth: 1}, + {Op: "ADD", Gas: 60000, GasCost: 3, Depth: 2}, + {Op: "STOP", Gas: 59000, GasCost: 0, Depth: 2}, + {Op: "STATICCALL", Gas: 90000, GasCost: 100, Depth: 1}, + {Op: "MUL", Gas: 50000, GasCost: 5, Depth: 2}, + {Op: "STOP", Gas: 49000, GasCost: 0, Depth: 2}, + {Op: "POP", Gas: 80000, GasCost: 2, Depth: 1}, + } + + gasUsed := []uint64{10000, 1000, 0, 10000, 1000, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 7) + + // DELEGATECALL: gas_self = 10000 - 1000 = 9000 + assert.Equal(t, uint64(9000), result[0], "DELEGATECALL gas_self") + + // STATICCALL: gas_self = 10000 - 1000 = 9000 + assert.Equal(t, uint64(9000), result[3], "STATICCALL gas_self") +} + +func TestComputeGasSelf_CallWithNoChildren(t *testing.T) { + // CALL to precompile or empty contract - no child opcodes + // In this case, gas_self should equal gas_used + structlogs := []execution.StructLog{ + {Op: "CALL", Gas: 100000, GasCost: 100, Depth: 1}, + {Op: "POP", Gas: 97400, GasCost: 2, Depth: 1}, // immediately back at depth 1 + } + + // gas_used: + // CALL: 100000 - 97400 = 2600 (just the CALL overhead, no child execution) + // POP: 2 + gasUsed := []uint64{2600, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 2) + + // No children, so gas_self = gas_used + assert.Equal(t, uint64(2600), result[0], "CALL with no children: gas_self == gas_used") + assert.Equal(t, uint64(2), result[1], "POP gas_self") +} + +func TestComputeGasSelf_DeeplyNestedCalls(t *testing.T) { + // Test 4 levels of nesting to ensure correct handling + structlogs := []execution.StructLog{ + {Op: "CALL", Gas: 100000, GasCost: 100, Depth: 1}, // index 0: A + {Op: "CALL", Gas: 90000, GasCost: 100, Depth: 2}, // index 1: B + {Op: "CALL", Gas: 80000, GasCost: 100, Depth: 3}, // index 2: C + {Op: "CALL", Gas: 70000, GasCost: 100, Depth: 4}, // index 3: D + {Op: "ADD", Gas: 60000, GasCost: 3, Depth: 5}, // index 4: innermost + {Op: "STOP", Gas: 59000, GasCost: 0, Depth: 5}, // index 5 + {Op: "STOP", Gas: 65000, GasCost: 0, Depth: 4}, // index 6 + {Op: "STOP", Gas: 74000, GasCost: 0, Depth: 3}, // index 7 + {Op: "STOP", Gas: 83000, GasCost: 0, Depth: 2}, // index 8 + {Op: "POP", Gas: 92000, GasCost: 2, Depth: 1}, // index 9 + } + + // gas_used: + // A[0]: 100000 - 92000 = 8000 + // B[1]: 90000 - 83000 = 7000 + // C[2]: 80000 - 74000 = 6000 + // D[3]: 70000 - 65000 = 5000 + // ADD[4]: 60000 - 59000 = 1000 + // STOP[5]: 0 + // STOP[6]: 0 + // STOP[7]: 0 + // STOP[8]: 0 + // POP[9]: 2 + gasUsed := []uint64{8000, 7000, 6000, 5000, 1000, 0, 0, 0, 0, 2} + + result := ComputeGasSelf(structlogs, gasUsed) + + require.Len(t, result, 10) + + // CALL A: direct children at depth 2 = [B, STOP] = indices 1, 8 + // gas_self = 8000 - (7000 + 0) = 1000 + assert.Equal(t, uint64(1000), result[0], "CALL A gas_self") + + // CALL B: direct children at depth 3 = [C, STOP] = indices 2, 7 + // gas_self = 7000 - (6000 + 0) = 1000 + assert.Equal(t, uint64(1000), result[1], "CALL B gas_self") + + // CALL C: direct children at depth 4 = [D, STOP] = indices 3, 6 + // gas_self = 6000 - (5000 + 0) = 1000 + assert.Equal(t, uint64(1000), result[2], "CALL C gas_self") + + // CALL D: direct children at depth 5 = [ADD, STOP] = indices 4, 5 + // gas_self = 5000 - (1000 + 0) = 4000 + assert.Equal(t, uint64(4000), result[3], "CALL D gas_self") +} diff --git a/pkg/processor/transaction/structlog/transaction_processing.go b/pkg/processor/transaction/structlog/transaction_processing.go index 1031ab8..b8d1996 100644 --- a/pkg/processor/transaction/structlog/transaction_processing.go +++ b/pkg/processor/transaction/structlog/transaction_processing.go @@ -3,15 +3,22 @@ package structlog import ( "context" "fmt" + "strings" + "sync" "time" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" "github.com/sirupsen/logrus" - "github.com/ethpandaops/execution-processor/pkg/common" + pcommon "github.com/ethpandaops/execution-processor/pkg/common" "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,15 +31,79 @@ 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"` - 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"` + MetaNetworkName string `json:"meta_network_name"` +} + +// isCallOpcode returns true if the opcode initiates a call that creates a child frame. +// Note: CREATE/CREATE2 always execute code (constructor), so they always increase depth. +// CALL-type opcodes may target EOAs (no code) or precompiles (special handling). +func isCallOpcode(op string) bool { + switch op { + case "CALL", "CALLCODE", "DELEGATECALL", "STATICCALL": + return true + default: + return false + } +} + +// precompileAddresses is populated from go-ethereum's precompile definitions. +// Precompile calls don't appear in trace_transaction results (unlike EOA calls which do). +// This is used to distinguish EOA calls from precompile calls when depth doesn't increase. +// +// Note: Low addresses like 0x5c, 0x60, etc. are NOT precompiles - they're real EOAs/contracts +// deployed early in Ethereum's history. Only addresses defined in go-ethereum are precompiles. +var ( + precompileAddresses map[common.Address]bool + precompileOnce sync.Once +) + +// initPrecompileAddresses builds the set of all known precompile addresses from go-ethereum. +// Uses PrecompiledContractsOsaka which includes all precompiles across all forks. +func initPrecompileAddresses() { + precompileAddresses = make(map[common.Address]bool, len(vm.PrecompiledContractsOsaka)) + for addr := range vm.PrecompiledContractsOsaka { + precompileAddresses[addr] = true + } +} + +// isPrecompile returns true if the address is a known EVM precompile. +// Precompile calls don't appear in trace_transaction results (unlike EOA calls which do). +func isPrecompile(addr string) bool { + precompileOnce.Do(initPrecompileAddresses) + + // Normalize to lowercase with 0x prefix and full 40 hex chars + hex := strings.TrimPrefix(strings.ToLower(addr), "0x") + + for len(hex) < 40 { + hex = "0" + hex + } + + return precompileAddresses[common.HexToAddress("0x"+hex)] } // ProcessSingleTransaction processes a single transaction and inserts its structlogs directly to ClickHouse. @@ -54,13 +125,13 @@ func (p *Processor) ProcessSingleTransaction(ctx context.Context, block *types.B // Send for direct insertion if err := p.insertStructlogs(ctx, structlogs); err != nil { - common.TransactionsProcessed.WithLabelValues(p.network.Name, "structlog", "failed").Inc() + pcommon.TransactionsProcessed.WithLabelValues(p.network.Name, "structlog", "failed").Inc() return 0, fmt.Errorf("failed to insert structlogs: %w", err) } // Record success metrics - common.TransactionsProcessed.WithLabelValues(p.network.Name, "structlog", "success").Inc() + pcommon.TransactionsProcessed.WithLabelValues(p.network.Name, "structlog", "success").Inc() return structlogCount, nil } @@ -78,6 +149,15 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, // Compute actual gas used for each structlog gasUsed := ComputeGasUsed(trace.Structlogs) + // Compute self gas (excludes child frame gas for CALL/CREATE opcodes) + gasSelf := ComputeGasSelf(trace.Structlogs, gasUsed) + + // Initialize call frame tracker + callTracker := NewCallTracker() + + // Pre-compute CREATE/CREATE2 addresses from trace stack + createAddresses := ComputeCreateAddresses(trace.Structlogs) + // Check if this is a big transaction and register if needed if totalCount >= p.bigTxManager.GetThreshold() { p.bigTxManager.RegisterBigTransaction(tx.Hash().String(), p) @@ -136,7 +216,29 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, // Producer - convert and send batches batch := make([]Structlog, 0, chunkSize) + + // Helper to send batch when full + sendBatchIfFull := func() error { + if len(batch) >= chunkSize { + select { + case batchChan <- batch: + batch = make([]Structlog, 0, chunkSize) + case <-ctx.Done(): + return ctx.Err() + } + } + + return nil + } + for i := 0; i < totalCount; i++ { + structLog := &trace.Structlogs[i] + + // Track call frame based on depth changes + frameID, framePath := callTracker.ProcessDepthChange(structLog.Depth) + + callToAddr := p.extractCallAddressWithCreate(structLog, i, createAddresses) + // Convert structlog batch = append(batch, Structlog{ UpdatedDateTime: NewClickHouseTime(time.Now()), @@ -147,32 +249,80 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, TransactionFailed: trace.Failed, TransactionReturnValue: trace.ReturnValue, Index: uint32(i), //nolint:gosec // index is bounded by structlogs length - ProgramCounter: trace.Structlogs[i].PC, - Operation: trace.Structlogs[i].Op, - Gas: trace.Structlogs[i].Gas, - GasCost: trace.Structlogs[i].GasCost, + ProgramCounter: structLog.PC, + Operation: structLog.Op, + Gas: structLog.Gas, + GasCost: structLog.GasCost, GasUsed: gasUsed[i], - Depth: trace.Structlogs[i].Depth, - ReturnData: trace.Structlogs[i].ReturnData, - Refund: trace.Structlogs[i].Refund, - Error: trace.Structlogs[i].Error, - CallToAddress: p.extractCallAddress(&trace.Structlogs[i]), + GasSelf: gasSelf[i], + Depth: structLog.Depth, + ReturnData: structLog.ReturnData, + Refund: structLog.Refund, + Error: structLog.Error, + CallToAddress: callToAddr, + CallFrameID: frameID, + CallFramePath: framePath, MetaNetworkName: p.network.Name, }) + // Check for EOA call: CALL-type opcode where depth stays the same (immediate return) + // and target is not a precompile (precompiles don't create trace frames) + if isCallOpcode(structLog.Op) && callToAddr != nil { + isEOACall := false + + if i+1 < totalCount { + // Next opcode exists - check if depth stayed the same + // Depth increase = entered contract code (not EOA) + // Depth decrease = call returned/failed (not EOA) + // Depth same = called EOA or precompile (immediate return) + nextDepth := trace.Structlogs[i+1].Depth + if nextDepth == structLog.Depth && !isPrecompile(*callToAddr) { + isEOACall = true + } + } + // Note: If last opcode is a CALL, we can't determine if it's EOA + // because we don't have a next opcode to compare depth with. + // These are typically failed calls at end of execution. + + if isEOACall { + // Emit synthetic structlog for EOA frame + eoaFrameID, eoaFramePath := callTracker.IssueFrameID() + + batch = append(batch, Structlog{ + UpdatedDateTime: NewClickHouseTime(time.Now()), + BlockNumber: block.Number().Uint64(), + TransactionHash: tx.Hash().String(), + TransactionIndex: uint32(index), //nolint:gosec // index is bounded by block.Transactions() length + TransactionGas: trace.Gas, + TransactionFailed: trace.Failed, + TransactionReturnValue: trace.ReturnValue, + Index: uint32(i), //nolint:gosec // Same index as parent CALL + ProgramCounter: 0, // No PC for EOA + Operation: "", // Empty = synthetic EOA frame + Gas: 0, + GasCost: 0, + GasUsed: 0, + GasSelf: 0, + Depth: structLog.Depth + 1, // One level deeper than caller + ReturnData: nil, + Refund: nil, + Error: structLog.Error, // Inherit error if CALL failed + CallToAddress: callToAddr, // The EOA address + CallFrameID: eoaFrameID, + CallFramePath: eoaFramePath, + MetaNetworkName: p.network.Name, + }) + } + } + // CRITICAL: Free original trace data immediately trace.Structlogs[i] = execution.StructLog{} // Send full batch - if len(batch) == chunkSize { - select { - case batchChan <- batch: - batch = make([]Structlog, 0, chunkSize) - case <-ctx.Done(): - close(batchChan) + if err := sendBatchIfFull(); err != nil { + close(batchChan) - return 0, ctx.Err() - } + return 0, err } } @@ -199,7 +349,7 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block *types.Block, } // Record success metrics - common.TransactionsProcessed.WithLabelValues(p.network.Name, "structlog", "success").Inc() + pcommon.TransactionsProcessed.WithLabelValues(p.network.Name, "structlog", "success").Inc() return totalCount, nil } @@ -225,15 +375,135 @@ func (p *Processor) getTransactionTrace(ctx context.Context, tx *types.Transacti return trace, nil } -// extractCallAddress extracts the call address from a structlog if it's a CALL operation. +// formatAddress normalizes an address to exactly 42 characters (0x + 40 hex). +// +// Background: The EVM is a 256-bit (32-byte) stack machine. ALL stack values are 32 bytes, +// including addresses. When execution clients like Erigon/Geth return debug traces, the +// stack array contains raw 32-byte values as hex strings (66 chars with 0x prefix). +// +// However, Ethereum addresses are only 160 bits (20 bytes, 40 hex chars). In EVM/ABI encoding, +// addresses are stored in the LOWER 160 bits of the 32-byte word (right-aligned, left-padded +// with zeros). For example, address 0x7a250d5630b4cf539739df2c5dacb4c659f2488d on the stack: +// +// 0x0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d +// |-------- upper 12 bytes (zeros) --------||---- lower 20 bytes (address) ----| +// +// Some contracts may have non-zero upper bytes in the stack value. The EVM ignores these +// when interpreting the value as an address - only the lower 20 bytes are used. +// +// This function handles three cases: +// 1. Short addresses (e.g., "0x1" for precompiles): left-pad with zeros to 40 hex chars +// 2. Full 32-byte stack values (66 chars): extract rightmost 40 hex chars (lower 160 bits) +// 3. Normal 42-char addresses: return as-is +func formatAddress(addr string) string { + // Remove 0x prefix if present + hex := strings.TrimPrefix(addr, "0x") + + // If longer than 40 chars, extract the lower 20 bytes (rightmost 40 hex chars). + // This handles raw 32-byte stack values from execution client traces. + if len(hex) > 40 { + hex = hex[len(hex)-40:] + } + + // Left-pad with zeros to 40 chars if shorter (handles precompiles like 0x1), + // then add 0x prefix + return fmt.Sprintf("0x%040s", hex) +} + +// extractCallAddress extracts the target address from a CALL-type opcode's stack. +// Handles CALL, CALLCODE, DELEGATECALL, and STATICCALL opcodes. +// For CREATE/CREATE2, use extractCallAddressWithCreate instead. +// +// Stack layout in Erigon/Geth debug traces: +// - Array index 0 = bottom of stack (oldest value, first pushed) +// - Array index len-1 = top of stack (newest value, first to be popped) +// +// When a CALL opcode executes, its arguments are at the top of the stack: +// +// CALL/CALLCODE: [..., retSize, retOffset, argsSize, argsOffset, value, addr, gas] +// DELEGATECALL/STATICCALL: [..., retSize, retOffset, argsSize, argsOffset, addr, gas] +// ^ ^ +// len-2 len-1 +// +// The address is always at Stack[len-2] (second from top), regardless of how many +// other values exist below the CALL arguments on the stack. +// +// Note: The stack value is a raw 32-byte word. The formatAddress function extracts +// the actual 20-byte address from the lower 160 bits. func (p *Processor) extractCallAddress(structLog *execution.StructLog) *string { - if structLog.Op == "CALL" && structLog.Stack != nil && len(*structLog.Stack) > 1 { + if structLog.Stack == nil || len(*structLog.Stack) < 2 { + return nil + } + + switch structLog.Op { + case "CALL", "CALLCODE", "DELEGATECALL", "STATICCALL": + // Extract the raw 32-byte stack value at the address position (second from top). + // formatAddress will normalize it to a proper 20-byte address. stackValue := (*structLog.Stack)[len(*structLog.Stack)-2] + addr := formatAddress(stackValue) + + return &addr + default: + return nil + } +} + +// extractCallAddressWithCreate extracts the call address, using createAddresses map for CREATE/CREATE2 opcodes. +func (p *Processor) extractCallAddressWithCreate(structLog *execution.StructLog, index int, createAddresses map[int]*string) *string { + // For CREATE/CREATE2, use the pre-computed address from the trace + if structLog.Op == "CREATE" || structLog.Op == "CREATE2" { + if createAddresses != nil { + return createAddresses[index] + } - return &stackValue + return nil } - return nil + return p.extractCallAddress(structLog) +} + +// ComputeCreateAddresses pre-computes the created contract addresses for all CREATE/CREATE2 opcodes. +// It scans the trace and extracts addresses from the stack when each CREATE's constructor returns. +// The returned map contains opcode index -> created address (only for CREATE/CREATE2 opcodes). +func ComputeCreateAddresses(structlogs []execution.StructLog) map[int]*string { + result := make(map[int]*string) + + // Track pending CREATE operations: (index, depth) + type pendingCreate struct { + index int + depth uint64 + } + + var pending []pendingCreate + + for i, log := range structlogs { + // Resolve pending CREATEs that have completed. + // A CREATE at depth D completes when we see an opcode at depth <= D + // (either immediately if CREATE failed, or after constructor returns). + for len(pending) > 0 { + last := pending[len(pending)-1] + + // If current opcode is at or below CREATE's depth and it's not the CREATE itself + if log.Depth <= last.depth && i > last.index { + // Extract address from top of stack (created address or 0 if failed) + if log.Stack != nil && len(*log.Stack) > 0 { + addr := formatAddress((*log.Stack)[len(*log.Stack)-1]) + result[last.index] = &addr + } + + pending = pending[:len(pending)-1] + } else { + break + } + } + + // Track new CREATE/CREATE2 + if log.Op == "CREATE" || log.Op == "CREATE2" { + pending = append(pending, pendingCreate{index: i, depth: log.Depth}) + } + } + + return result } // ExtractStructlogs extracts structlog data from a transaction without inserting to database. @@ -242,7 +512,7 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i defer func() { duration := time.Since(start) - common.TransactionProcessingDuration.WithLabelValues(p.network.Name, "structlog").Observe(duration.Seconds()) + pcommon.TransactionProcessingDuration.WithLabelValues(p.network.Name, "structlog").Observe(duration.Seconds()) }() // Get execution node @@ -270,16 +540,23 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i // Compute actual gas used for each structlog gasUsed := ComputeGasUsed(trace.Structlogs) + // Compute self gas (excludes child frame gas for CALL/CREATE opcodes) + gasSelf := ComputeGasSelf(trace.Structlogs, gasUsed) + + // Initialize call frame tracker + callTracker := NewCallTracker() + + // Pre-compute CREATE/CREATE2 addresses from trace stack + createAddresses := ComputeCreateAddresses(trace.Structlogs) + // Pre-allocate slice for better memory efficiency structlogs = make([]Structlog, 0, len(trace.Structlogs)) for i, structLog := range trace.Structlogs { - var callToAddress *string + // Track call frame based on depth changes + frameID, framePath := callTracker.ProcessDepthChange(structLog.Depth) - if structLog.Op == "CALL" && structLog.Stack != nil && len(*structLog.Stack) > 1 { - stackValue := (*structLog.Stack)[len(*structLog.Stack)-2] - callToAddress = &stackValue - } + callToAddr := p.extractCallAddressWithCreate(&structLog, i, createAddresses) row := Structlog{ UpdatedDateTime: NewClickHouseTime(time.Now()), @@ -295,15 +572,70 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block *types.Block, i Gas: structLog.Gas, GasCost: structLog.GasCost, GasUsed: gasUsed[i], + GasSelf: gasSelf[i], Depth: structLog.Depth, ReturnData: structLog.ReturnData, Refund: structLog.Refund, Error: structLog.Error, - CallToAddress: callToAddress, + CallToAddress: callToAddr, + CallFrameID: frameID, + CallFramePath: framePath, MetaNetworkName: p.network.Name, } structlogs = append(structlogs, row) + + // Check for EOA call: CALL-type opcode where depth stays the same (immediate return) + // and target is not a precompile (precompiles don't create trace frames) + if isCallOpcode(structLog.Op) && callToAddr != nil { + isEOACall := false + + if i+1 < len(trace.Structlogs) { + // Next opcode exists - check if depth stayed the same + // Depth increase = entered contract code (not EOA) + // Depth decrease = call returned/failed (not EOA) + // Depth same = called EOA or precompile (immediate return) + nextDepth := trace.Structlogs[i+1].Depth + if nextDepth == structLog.Depth && !isPrecompile(*callToAddr) { + isEOACall = true + } + } + // Note: If last opcode is a CALL, we can't determine if it's EOA + // because we don't have a next opcode to compare depth with. + // These are typically failed calls at end of execution. + + if isEOACall { + // Emit synthetic structlog for EOA frame + eoaFrameID, eoaFramePath := callTracker.IssueFrameID() + + eoaRow := Structlog{ + UpdatedDateTime: NewClickHouseTime(time.Now()), + BlockNumber: block.Number().Uint64(), + TransactionHash: tx.Hash().String(), + TransactionIndex: uIndex, + TransactionGas: trace.Gas, + TransactionFailed: trace.Failed, + TransactionReturnValue: trace.ReturnValue, + Index: uint32(i), //nolint:gosec // Same index as parent CALL + ProgramCounter: 0, // No PC for EOA + Operation: "", // Empty = synthetic EOA frame + Gas: 0, + GasCost: 0, + GasUsed: 0, + GasSelf: 0, + Depth: structLog.Depth + 1, // One level deeper than caller + ReturnData: nil, + Refund: nil, + Error: structLog.Error, // Inherit error if CALL failed + CallToAddress: callToAddr, // The EOA address + CallFrameID: eoaFrameID, + CallFramePath: eoaFramePath, + MetaNetworkName: p.network.Name, + } + + structlogs = append(structlogs, eoaRow) + } + } } // Clear the original trace data to free memory