Skip to content

Commit 73405a9

Browse files
committed
test: add unit tests for precompile gas-splitting and synthetic frames
refactor: extract precompile gas from CALL overhead and emit synthetic frame
1 parent 8e71e17 commit 73405a9

2 files changed

Lines changed: 317 additions & 8 deletions

File tree

pkg/processor/transaction/structlog_agg/aggregator_test.go

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,3 +645,292 @@ func TestMapOpcodeToCallType(t *testing.T) {
645645
})
646646
}
647647
}
648+
649+
func TestFrameAggregator_PrecompileFrame(t *testing.T) {
650+
// Test that precompile calls (CALL to 0x01-0x11, 0x100) emit synthetic frames
651+
// with the correct gas split: parent CALL retains overhead (100), precompile
652+
// frame gets the remaining execution gas.
653+
aggregator := NewFrameAggregator()
654+
655+
precompileAddr := "0x0000000000000000000000000000000000000001" // ecrecover
656+
657+
// Root frame opcodes
658+
aggregator.ProcessStructlog(&execution.StructLog{
659+
Op: "PUSH1",
660+
Depth: 1,
661+
Gas: 10000,
662+
}, 0, 0, []uint32{0}, 3, 3, nil, nil)
663+
664+
// CALL to precompile: gasSelf=3100 (100 overhead + 3000 precompile execution).
665+
// With precompile gas extraction:
666+
// effectiveGasSelf = 100 (overhead only)
667+
// precompileGas = 3000 (execution gas)
668+
aggregator.ProcessStructlog(&execution.StructLog{
669+
Op: "CALL",
670+
Depth: 1,
671+
Gas: 9997,
672+
}, 1, 0, []uint32{0}, 3100, 100, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1})
673+
674+
// Synthetic precompile frame (gas = precompileGas = 3000)
675+
aggregator.ProcessStructlog(&execution.StructLog{
676+
Op: "",
677+
Depth: 2,
678+
}, 1, 1, []uint32{0, 1}, 3000, 3000, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1})
679+
680+
// Back to root frame
681+
aggregator.ProcessStructlog(&execution.StructLog{
682+
Op: "STOP",
683+
Depth: 1,
684+
Gas: 6897,
685+
}, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2})
686+
687+
trace := &execution.TraceTransaction{
688+
Gas: 10000,
689+
Failed: false,
690+
}
691+
692+
frames := aggregator.Finalize(trace, 5000)
693+
694+
// Should have 2 summary rows: root + precompile synthetic frame
695+
assert.Equal(t, 2, countSummaryRows(frames))
696+
697+
rootFrame := getSummaryRow(frames, 0)
698+
precompileFrame := getSummaryRow(frames, 1)
699+
700+
require.NotNil(t, rootFrame, "root frame should exist")
701+
require.NotNil(t, precompileFrame, "precompile frame should exist")
702+
703+
// Root frame: 3 real opcodes (PUSH1, CALL, STOP)
704+
assert.Equal(t, uint64(3), rootFrame.OpcodeCount)
705+
706+
// Precompile frame: 0 opcodes (synthetic), gas > 0
707+
assert.Equal(t, uint64(0), precompileFrame.OpcodeCount)
708+
assert.Equal(t, "CALL", precompileFrame.CallType)
709+
require.NotNil(t, precompileFrame.TargetAddress)
710+
assert.Equal(t, precompileAddr, *precompileFrame.TargetAddress)
711+
712+
// Precompile frame gas_cumulative should reflect precompile execution gas
713+
assert.Equal(t, uint64(3000), precompileFrame.GasCumulative)
714+
715+
// Verify parent CALL opcode row has only overhead gas (100)
716+
callRow := getOpcodeRow(frames, 0, "CALL")
717+
require.NotNil(t, callRow)
718+
assert.Equal(t, uint64(100), callRow.Gas, "parent CALL gas should only include overhead")
719+
}
720+
721+
func TestFrameAggregator_PrecompileGasSplitInvariant(t *testing.T) {
722+
// Verify the gas split invariant:
723+
// SUM(parent CALL overhead) + SUM(precompile frame gas) == SUM(original CALL gasSelf)
724+
aggregator := NewFrameAggregator()
725+
726+
precompileAddr := "0x0000000000000000000000000000000000000002" // sha256
727+
728+
originalGasSelf := uint64(5100) // 100 overhead + 5000 precompile
729+
overhead := uint64(100)
730+
precompileGas := originalGasSelf - overhead
731+
732+
aggregator.ProcessStructlog(&execution.StructLog{
733+
Op: "PUSH1",
734+
Depth: 1,
735+
Gas: 20000,
736+
}, 0, 0, []uint32{0}, 3, 3, nil, nil)
737+
738+
// CALL with effectiveGasSelf = overhead
739+
aggregator.ProcessStructlog(&execution.StructLog{
740+
Op: "CALL",
741+
Depth: 1,
742+
Gas: 19997,
743+
}, 1, 0, []uint32{0}, originalGasSelf, overhead, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1})
744+
745+
// Synthetic precompile frame
746+
aggregator.ProcessStructlog(&execution.StructLog{
747+
Op: "",
748+
Depth: 2,
749+
}, 1, 1, []uint32{0, 1}, precompileGas, precompileGas, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1})
750+
751+
aggregator.ProcessStructlog(&execution.StructLog{
752+
Op: "STOP",
753+
Depth: 1,
754+
Gas: 14897,
755+
}, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2})
756+
757+
trace := &execution.TraceTransaction{Gas: 20000, Failed: false}
758+
frames := aggregator.Finalize(trace, 10000)
759+
760+
// Verify invariant: CALL opcode gas + precompile frame gas == original gasSelf
761+
callRow := getOpcodeRow(frames, 0, "CALL")
762+
precompileFrame := getSummaryRow(frames, 1)
763+
764+
require.NotNil(t, callRow)
765+
require.NotNil(t, precompileFrame)
766+
767+
assert.Equal(t, originalGasSelf, callRow.Gas+precompileFrame.GasCumulative,
768+
"gas split invariant: CALL overhead + precompile gas == original gasSelf")
769+
}
770+
771+
func TestFrameAggregator_EOAFrameUnchanged(t *testing.T) {
772+
// Verify that EOA calls still produce synthetic frames with gas=0
773+
// (unchanged behavior after precompile frame changes).
774+
aggregator := NewFrameAggregator()
775+
776+
eoaAddr := "0x1234567890123456789012345678901234567890"
777+
778+
aggregator.ProcessStructlog(&execution.StructLog{
779+
Op: "PUSH1",
780+
Depth: 1,
781+
Gas: 10000,
782+
}, 0, 0, []uint32{0}, 3, 3, nil, nil)
783+
784+
// CALL to EOA: gasSelf=100, no precompile gas extraction
785+
aggregator.ProcessStructlog(&execution.StructLog{
786+
Op: "CALL",
787+
Depth: 1,
788+
Gas: 9997,
789+
}, 1, 0, []uint32{0}, 100, 100, &eoaAddr, &execution.StructLog{Op: "PUSH1", Depth: 1})
790+
791+
// Synthetic EOA frame (gas = 0)
792+
aggregator.ProcessStructlog(&execution.StructLog{
793+
Op: "",
794+
Depth: 2,
795+
}, 1, 1, []uint32{0, 1}, 0, 0, &eoaAddr, &execution.StructLog{Op: "CALL", Depth: 1})
796+
797+
aggregator.ProcessStructlog(&execution.StructLog{
798+
Op: "STOP",
799+
Depth: 1,
800+
Gas: 9897,
801+
}, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2})
802+
803+
trace := &execution.TraceTransaction{Gas: 10000, Failed: false}
804+
frames := aggregator.Finalize(trace, 5000)
805+
806+
assert.Equal(t, 2, countSummaryRows(frames))
807+
808+
eoaFrame := getSummaryRow(frames, 1)
809+
require.NotNil(t, eoaFrame)
810+
811+
// EOA frame: gas = 0, gas_cumulative = 0
812+
assert.Equal(t, uint64(0), eoaFrame.Gas)
813+
assert.Equal(t, uint64(0), eoaFrame.GasCumulative)
814+
assert.Equal(t, uint64(0), eoaFrame.OpcodeCount)
815+
require.NotNil(t, eoaFrame.TargetAddress)
816+
assert.Equal(t, eoaAddr, *eoaFrame.TargetAddress)
817+
}
818+
819+
func TestFrameAggregator_MultiplePrecompileCalls(t *testing.T) {
820+
// Test transaction with multiple precompile calls producing correct
821+
// number of synthetic frames, each with correct gas.
822+
aggregator := NewFrameAggregator()
823+
824+
ecrecoverAddr := "0x0000000000000000000000000000000000000001"
825+
sha256Addr := "0x0000000000000000000000000000000000000002"
826+
827+
aggregator.ProcessStructlog(&execution.StructLog{
828+
Op: "PUSH1",
829+
Depth: 1,
830+
Gas: 50000,
831+
}, 0, 0, []uint32{0}, 3, 3, nil, nil)
832+
833+
// First precompile call: ecrecover (gas = 3100 = 100 + 3000)
834+
aggregator.ProcessStructlog(&execution.StructLog{
835+
Op: "CALL",
836+
Depth: 1,
837+
Gas: 49997,
838+
}, 1, 0, []uint32{0}, 3100, 100, &ecrecoverAddr, &execution.StructLog{Op: "PUSH1", Depth: 1})
839+
840+
// Synthetic frame for ecrecover
841+
aggregator.ProcessStructlog(&execution.StructLog{
842+
Op: "",
843+
Depth: 2,
844+
}, 1, 1, []uint32{0, 1}, 3000, 3000, &ecrecoverAddr, &execution.StructLog{Op: "CALL", Depth: 1})
845+
846+
// Some opcodes between the two precompile calls
847+
aggregator.ProcessStructlog(&execution.StructLog{
848+
Op: "PUSH1",
849+
Depth: 1,
850+
Gas: 46897,
851+
}, 2, 0, []uint32{0}, 3, 3, nil, &execution.StructLog{Op: "", Depth: 2})
852+
853+
// Second precompile call: sha256 (gas = 1100 = 100 + 1000)
854+
aggregator.ProcessStructlog(&execution.StructLog{
855+
Op: "STATICCALL",
856+
Depth: 1,
857+
Gas: 46894,
858+
}, 3, 0, []uint32{0}, 1100, 100, &sha256Addr, &execution.StructLog{Op: "PUSH1", Depth: 1})
859+
860+
// Synthetic frame for sha256
861+
aggregator.ProcessStructlog(&execution.StructLog{
862+
Op: "",
863+
Depth: 2,
864+
}, 3, 2, []uint32{0, 2}, 1000, 1000, &sha256Addr, &execution.StructLog{Op: "STATICCALL", Depth: 1})
865+
866+
aggregator.ProcessStructlog(&execution.StructLog{
867+
Op: "STOP",
868+
Depth: 1,
869+
Gas: 45794,
870+
}, 4, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2})
871+
872+
trace := &execution.TraceTransaction{Gas: 50000, Failed: false}
873+
frames := aggregator.Finalize(trace, 30000)
874+
875+
// Should have 3 summary rows: root + ecrecover + sha256
876+
assert.Equal(t, 3, countSummaryRows(frames))
877+
878+
ecrecoverFrame := getSummaryRow(frames, 1)
879+
sha256Frame := getSummaryRow(frames, 2)
880+
881+
require.NotNil(t, ecrecoverFrame)
882+
require.NotNil(t, sha256Frame)
883+
884+
assert.Equal(t, uint64(3000), ecrecoverFrame.GasCumulative)
885+
require.NotNil(t, ecrecoverFrame.TargetAddress)
886+
assert.Equal(t, ecrecoverAddr, *ecrecoverFrame.TargetAddress)
887+
888+
assert.Equal(t, uint64(1000), sha256Frame.GasCumulative)
889+
require.NotNil(t, sha256Frame.TargetAddress)
890+
assert.Equal(t, sha256Addr, *sha256Frame.TargetAddress)
891+
}
892+
893+
func TestFrameAggregator_PrecompileGasSelfLessThanOverhead(t *testing.T) {
894+
// Edge case: gasSelf <= overhead (100). No gas split occurs —
895+
// precompileGas stays 0, effectiveGasSelf stays at gasSelf.
896+
aggregator := NewFrameAggregator()
897+
898+
precompileAddr := "0x0000000000000000000000000000000000000004" // identity
899+
900+
aggregator.ProcessStructlog(&execution.StructLog{
901+
Op: "PUSH1",
902+
Depth: 1,
903+
Gas: 10000,
904+
}, 0, 0, []uint32{0}, 3, 3, nil, nil)
905+
906+
// CALL to precompile with gasSelf=50 (less than overhead=100)
907+
// This shouldn't split — effectiveGasSelf stays 50
908+
aggregator.ProcessStructlog(&execution.StructLog{
909+
Op: "CALL",
910+
Depth: 1,
911+
Gas: 9997,
912+
}, 1, 0, []uint32{0}, 50, 50, &precompileAddr, &execution.StructLog{Op: "PUSH1", Depth: 1})
913+
914+
// Synthetic frame with gas=0 (no precompile gas extracted)
915+
aggregator.ProcessStructlog(&execution.StructLog{
916+
Op: "",
917+
Depth: 2,
918+
}, 1, 1, []uint32{0, 1}, 0, 0, &precompileAddr, &execution.StructLog{Op: "CALL", Depth: 1})
919+
920+
aggregator.ProcessStructlog(&execution.StructLog{
921+
Op: "STOP",
922+
Depth: 1,
923+
Gas: 9947,
924+
}, 2, 0, []uint32{0}, 0, 0, nil, &execution.StructLog{Op: "", Depth: 2})
925+
926+
trace := &execution.TraceTransaction{Gas: 10000, Failed: false}
927+
frames := aggregator.Finalize(trace, 5000)
928+
929+
callRow := getOpcodeRow(frames, 0, "CALL")
930+
require.NotNil(t, callRow)
931+
assert.Equal(t, uint64(50), callRow.Gas, "CALL gas should remain 50 when gasSelf <= overhead")
932+
933+
precompileFrame := getSummaryRow(frames, 1)
934+
require.NotNil(t, precompileFrame)
935+
assert.Equal(t, uint64(0), precompileFrame.GasCumulative, "precompile frame gas should be 0")
936+
}

pkg/processor/transaction/structlog_agg/transaction_processing.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,21 +130,41 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc
130130
// Get call target address
131131
callToAddr := p.extractCallAddressWithCreate(sl, i, createAddresses)
132132

133-
// Process this structlog into the aggregator
134-
aggregator.ProcessStructlog(sl, i, frameID, framePath, gasUsed[i], gasSelf[i], callToAddr, prevStructlog)
133+
// Before processing parent CALL: detect precompile and compute gas split.
134+
// Precompile gas = gasSelf minus CALL overhead (warm access cost = 100).
135+
// Precompiles are always warm (EIP-2929 pre-warms them).
136+
// Note: using overhead=100 (warm access only). When Plan B adds memory
137+
// expansion data, this should be refined to overhead=100+memExp.
138+
effectiveGasSelf := gasSelf[i]
139+
140+
var precompileGas uint64
141+
142+
if isCallOpcode(sl.Op) && callToAddr != nil && i+1 < len(trace.Structlogs) {
143+
nextDepth := trace.Structlogs[i+1].Depth
144+
if nextDepth == sl.Depth && isPrecompile(*callToAddr) {
145+
overhead := uint64(100)
146+
if gasSelf[i] > overhead {
147+
precompileGas = gasSelf[i] - overhead
148+
effectiveGasSelf = overhead
149+
}
150+
}
151+
}
152+
153+
// Process this structlog into the aggregator with (possibly reduced) gasSelf
154+
aggregator.ProcessStructlog(sl, i, frameID, framePath, gasUsed[i], effectiveGasSelf, callToAddr, prevStructlog)
135155

136-
// Check for EOA call: CALL-type opcode where depth stays the same (immediate return)
137-
// and target is not a precompile
156+
// Emit synthetic frame for ALL immediate-return CALLs (EOA + precompile).
157+
// For EOA calls: precompileGas = 0, so frame has gas=0 (unchanged behavior).
158+
// For precompile calls: frame has gas=precompileGas.
138159
if isCallOpcode(sl.Op) && callToAddr != nil {
139160
if i+1 < len(trace.Structlogs) {
140161
nextDepth := trace.Structlogs[i+1].Depth
141-
if nextDepth == sl.Depth && !isPrecompile(*callToAddr) {
142-
// Emit synthetic EOA frame
143-
eoaFrameID, eoaFramePath := callTracker.issueFrameID()
162+
if nextDepth == sl.Depth {
163+
synthFrameID, synthFramePath := callTracker.issueFrameID()
144164
aggregator.ProcessStructlog(&execution.StructLog{
145165
Op: "",
146166
Depth: sl.Depth + 1,
147-
}, i, eoaFrameID, eoaFramePath, 0, 0, callToAddr, sl)
167+
}, i, synthFrameID, synthFramePath, precompileGas, precompileGas, callToAddr, sl)
148168
}
149169
}
150170
}

0 commit comments

Comments
 (0)