Skip to content

Commit 0e73b87

Browse files
committed
test(evm): make the differential prove it ran what it claims
1 parent c7f0027 commit 0e73b87

1 file changed

Lines changed: 74 additions & 44 deletions

File tree

src/Nethermind/Nethermind.Evm.Test/CodeAnalysis/StreamGasFuzzTests.cs

Lines changed: 74 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Text;
7+
using System.Threading;
78
using Nethermind.Core;
89
using Nethermind.Core.Crypto;
910
using Nethermind.Core.Specs;
@@ -16,18 +17,12 @@
1617
namespace Nethermind.Evm.Test.CodeAnalysis;
1718

1819
/// <summary>
19-
/// Randomized differential over the opcode alphabet the block analyzer treats specially: jump
20-
/// targets, static and dynamic jumps, the fused glue pairs, constant folds, the peephole-consumed
21-
/// shapes and the boundary ops that keep a block open. The hand-written differential cases cover
22-
/// the shapes we thought of; a cross-client gas mismatch showed that the shapes we did not think
23-
/// of are the ones that break, so this walks the space instead. Every generated program runs on
24-
/// both interpreters twice - once with ample gas and once with a seed-derived budget that starves
25-
/// it mid-run, which is what forces the metered fallback and the out-of-gas edges - and gas,
26-
/// status and output must all match exactly. Gas alone cannot see a wrong value that does not
27-
/// flip a branch, so the top of the stack is returned as output. A seed that fails prints its
28-
/// bytecode, which is the reproduction.
20+
/// Randomized differential between the stream interpreter and the bytecode loop. Each program runs
21+
/// on both twice - ample gas, then a budget that starves it mid-run - and gas, status and output
22+
/// must match exactly. A failing seed prints its bytecode as the reproduction.
2923
/// </summary>
30-
[TestFixture]
24+
// Mutates process-wide StreamInterpreter statics, so it must not run alongside other EVM tests.
25+
[TestFixture, NonParallelizable]
3126
public class StreamGasFuzzTests : VirtualMachineTestsBase
3227
{
3328
protected override ulong BlockNumber => MainnetSpecProvider.ParisBlockNumber + 4;
@@ -41,29 +36,39 @@ public class StreamGasFuzzTests : VirtualMachineTestsBase
4136
public void StreamExecution_MatchesByteCodeLoop_OverRandomJumpHeavyPrograms()
4237
{
4338
List<string> failures = [];
39+
int completed = 0;
40+
int starved = 0;
4441
for (int seed = 0; seed < Programs; seed++)
4542
{
4643
byte[] code = Generate(seed);
4744

4845
ExecutionCapture baseline = RunFor(code, useStream: false, AmpleGas);
4946
ExecutionCapture streamed = RunFor(code, useStream: true, AmpleGas);
5047
Compare(failures, seed, "ample", code, baseline, streamed);
48+
if (baseline.StatusCode == Evm.StatusCode.Success) completed++;
5149

52-
// A budget cut somewhere inside the run starves a block precharge, so the tail executes
53-
// metered from raw code - the paths a run that always completes never crosses.
5450
if (baseline.GasSpent > GasCostOf.Transaction)
5551
{
5652
ulong execGas = baseline.GasSpent - GasCostOf.Transaction;
57-
ulong budget = GasCostOf.Transaction + execGas * (ulong)(seed % 97) / 97;
53+
ulong budget = GasCostOf.Transaction + execGas * (ulong)(1 + seed % 96) / 97;
5854
ExecutionCapture tightBaseline = RunFor(code, useStream: false, budget);
5955
ExecutionCapture tightStreamed = RunFor(code, useStream: true, budget);
6056
Compare(failures, seed, $"tight budget {budget}", code, tightBaseline, tightStreamed);
57+
if (tightBaseline.StatusCode == Evm.StatusCode.Failure) starved++;
6158
}
6259

6360
if (failures.Count >= 5)
6461
break;
6562
}
6663

64+
using (Assert.EnterMultipleScope())
65+
{
66+
Assert.That(completed, Is.GreaterThan(Programs / 2),
67+
"most generated programs must run to completion, or the comparison is between two immediate halts");
68+
Assert.That(starved, Is.GreaterThan(Programs / 4),
69+
"the tight-budget pass must actually run out of gas, or the metered fallback is never crossed");
70+
}
71+
6772
if (failures.Count > 0)
6873
{
6974
StringBuilder message = new($"{failures.Count} run(s) diverged between the two interpreters:");
@@ -87,22 +92,18 @@ private static void Compare(List<string> failures, int seed, string label, byte[
8792
}
8893

8994
/// <summary>
90-
/// Builds a program whose jump targets are always real JUMPDESTs, so the run exercises jump
91-
/// accounting rather than invalid-destination failures, and whose backward jumps are all
92-
/// conditional on a decrementing counter so it terminates. Weighted toward the constructs the
93-
/// analyzer rewrites. Ends by returning the top of the stack, so a wrong value diverges even
94-
/// when it never flips a branch or moves gas.
95+
/// Builds a program weighted toward the constructs the analyzer rewrites. Jump targets are
96+
/// always real JUMPDESTs and conditional branches decrement the word they test, so a backward
97+
/// jump makes progress towards leaving the loop instead of spinning until the gas runs out.
9598
/// </summary>
9699
private static byte[] Generate(int seed)
97100
{
98101
Random random = new(seed);
99102
List<byte> code = [];
100103
List<int> jumpDests = [];
101104

102-
// A leading counter lets generated loops decrement toward zero instead of spinning, and a
103-
// dozen words under it give the deep DUP and SWAP forms something to reach - the first
104-
// version of this generator only ever produced depth one and two, which is why it passed
105-
// while a permutation-coalescing bug that only shows past that depth broke every real call.
105+
// A leading counter for loops to decrement, and a dozen words under it so the deep DUP and
106+
// SWAP forms have something to reach.
106107
code.AddRange([(byte)Instruction.PUSH1, (byte)(1 + random.Next(3))]);
107108
for (int i = 0; i < 12; i++)
108109
{
@@ -113,7 +114,7 @@ private static byte[] Generate(int seed)
113114
int slots = 6 + random.Next(18);
114115
for (int i = 0; i < slots; i++)
115116
{
116-
switch (random.Next(20))
117+
switch (random.Next(24))
117118
{
118119
case 0:
119120
jumpDests.Add(code.Count);
@@ -138,8 +139,7 @@ private static byte[] Generate(int seed)
138139
code.AddRange([(byte)Instruction.DUP1, (byte)Instruction.ISZERO]);
139140
break;
140141
case 7:
141-
// A run of several permutation ops at mixed depths, the shape the coalescing pass
142-
// rewrites and the shape the first generator never produced.
142+
// Permutation ops at mixed depths, the shape the coalescing pass rewrites.
143143
for (int k = random.Next(2, 6); k > 0; k--)
144144
{
145145
code.Add(random.Next(3) switch
@@ -155,37 +155,43 @@ private static byte[] Generate(int seed)
155155
code.AddRange([(byte)Instruction.PUSH1, 0x20, (byte)Instruction.PUSH1, 0x00, (byte)Instruction.MSTORE]);
156156
break;
157157
case 9 when jumpDests.Count > 0:
158-
// Static conditional jump: PUSH2 target + JUMPI, the fused shape.
158+
// Static conditional jump, the fused shape.
159159
int condDest = jumpDests[random.Next(jumpDests.Count)];
160-
code.AddRange([(byte)Instruction.DUP1, (byte)Instruction.ISZERO, (byte)Instruction.PUSH2, (byte)(condDest >> 8), (byte)condDest, (byte)Instruction.JUMPI]);
160+
code.AddRange([
161+
(byte)Instruction.PUSH1, 0x01, (byte)Instruction.SWAP1, (byte)Instruction.SUB,
162+
(byte)Instruction.DUP1,
163+
(byte)Instruction.PUSH2, (byte)(condDest >> 8), (byte)condDest, (byte)Instruction.JUMPI]);
161164
break;
162165
case 10 when jumpDests.Count > 0:
163-
// Dynamic conditional jump: the target arrives through the stack.
166+
// Dynamic conditional jump: the SWAP1 stops the static-jump fusion from
167+
// claiming the pair, which is the point of this arm.
164168
int dynDest = jumpDests[random.Next(jumpDests.Count)];
165-
code.AddRange([(byte)Instruction.PUSH1, 0x00, (byte)Instruction.PUSH2, (byte)(dynDest >> 8), (byte)dynDest, (byte)Instruction.SWAP1, (byte)Instruction.JUMPI]);
169+
code.AddRange([
170+
(byte)Instruction.PUSH2, (byte)(dynDest >> 8), (byte)dynDest,
171+
(byte)Instruction.PUSH1, (byte)random.Next(2),
172+
(byte)Instruction.SWAP1, (byte)Instruction.JUMPI]);
166173
break;
167174
case 11:
168-
// AND alone and AND feeding ISZERO, the fused compare-to-zero pair.
169175
code.AddRange([(byte)Instruction.PUSH1, (byte)random.Next(256), (byte)Instruction.PUSH1, (byte)random.Next(256), (byte)Instruction.AND]);
170176
if (random.Next(2) == 0) code.Add((byte)Instruction.ISZERO);
171177
break;
172178
case 12:
173-
// Division and modulo, with a zero divisor often enough to hit that fold.
179+
// Zero divisor often enough to hit that fold.
174180
code.AddRange([
175181
(byte)Instruction.PUSH1, (byte)(random.Next(4) == 0 ? 0 : random.Next(256)),
176182
(byte)Instruction.PUSH1, (byte)random.Next(256),
177183
(byte)(random.Next(2) == 0 ? Instruction.DIV : Instruction.MOD)]);
178184
break;
179185
case 13:
180-
// Shifts, sometimes past 255 so saturation folds and cores agree.
186+
// Sometimes past 255, so saturation folds and cores must agree.
181187
if (random.Next(3) == 0)
182188
code.AddRange([(byte)Instruction.PUSH1, (byte)random.Next(256), (byte)Instruction.PUSH2, 0x01, 0x00]);
183189
else
184190
code.AddRange([(byte)Instruction.PUSH1, (byte)random.Next(256), (byte)Instruction.PUSH1, (byte)random.Next(256)]);
185191
code.Add((byte)(random.Next(2) == 0 ? Instruction.SHL : Instruction.SHR));
186192
break;
187193
case 14:
188-
// Wide constant pair feeding an operator: the fold path through the constant pool.
194+
// The fold path through the constant pool.
189195
random.NextBytes(wide);
190196
code.Add((byte)Instruction.PUSH32);
191197
code.AddRange(wide);
@@ -195,14 +201,13 @@ private static byte[] Generate(int seed)
195201
code.Add((byte)(random.Next(3) switch { 0 => Instruction.ADD, 1 => Instruction.MUL, _ => Instruction.AND }));
196202
break;
197203
case 15:
198-
// Unconditional static jump to the very next instruction: the fused StaticJump
199-
// shape, and a jump arrival on a marker whose gas the analyzer may have elided.
204+
// Fused StaticJump, arriving on a marker whose gas the analyzer may have elided.
200205
int target = code.Count + 4;
201206
code.AddRange([(byte)Instruction.PUSH2, (byte)(target >> 8), (byte)target, (byte)Instruction.JUMP, (byte)Instruction.JUMPDEST]);
202207
jumpDests.Add(target);
203208
break;
204209
case 16:
205-
// Boundary ops that keep a block open - the widest behavioural change.
210+
// Boundary ops that keep a block open.
206211
switch (random.Next(5))
207212
{
208213
case 0: code.AddRange([(byte)Instruction.PUSH1, (byte)random.Next(64), (byte)Instruction.MLOAD]); break;
@@ -214,12 +219,34 @@ private static byte[] Generate(int seed)
214219

215220
break;
216221
case 17:
217-
// A table handler that consumes its successor and lands past it - adjacent to a
218-
// random JUMPDEST from case 0 this is the elided-marker landing.
222+
// A handler that consumes its successor and lands past it; next to a JUMPDEST
223+
// from case 0 this is the elided-marker landing.
219224
code.AddRange([(byte)Instruction.PUSH1, (byte)random.Next(256), (byte)Instruction.EXTCODESIZE, (byte)Instruction.ISZERO]);
220225
break;
226+
case 22:
227+
// Constant shift feeding a subtraction, at saturating amounts too.
228+
code.AddRange([
229+
(byte)Instruction.PUSH1, (byte)random.Next(256),
230+
(byte)Instruction.PUSH1, (byte)(random.Next(4) == 0 ? 0xFF : random.Next(40)),
231+
(byte)Instruction.SHL, (byte)Instruction.SUB]);
232+
break;
233+
case 21:
234+
code.AddRange([
235+
(byte)Instruction.PUSH1, (byte)random.Next(256),
236+
(byte)Instruction.PUSH1, (byte)random.Next(256),
237+
(byte)Instruction.SUB, (byte)Instruction.AND]);
238+
break;
239+
case 19:
240+
code.AddRange([(byte)Instruction.PUSH1, (byte)random.Next(256), (byte)((byte)Instruction.DUP1 + random.Next(8))]);
241+
break;
242+
case 20:
243+
code.AddRange([
244+
(byte)Instruction.PUSH1, (byte)random.Next(256),
245+
(byte)Instruction.PUSH1, (byte)random.Next(9),
246+
(byte)(random.Next(4) switch { 0 => Instruction.SHL, 1 => Instruction.SHR, 2 => Instruction.ADD, _ => Instruction.DIV })]);
247+
break;
221248
case 18:
222-
// Pushes of every width, so folds and pool references cross the PUSH8/PUSH9 seam.
249+
// Every push width, so folds and pool references cross the PUSH8/PUSH9 seam.
223250
int width = 3 + random.Next(30);
224251
code.Add((byte)((byte)Instruction.PUSH1 + width - 1));
225252
for (int k = 0; k < width; k++) code.Add((byte)random.Next(256));
@@ -230,8 +257,8 @@ private static byte[] Generate(int seed)
230257
}
231258
}
232259

233-
// Return the top of the stack so a wrong value is observable; an empty stack underflows
234-
// identically on both interpreters, which is a comparison too.
260+
// Return the top of the stack, so a wrong value is observable even when it never flips a
261+
// branch or moves gas.
235262
code.AddRange([
236263
(byte)Instruction.PUSH1, 0x00, (byte)Instruction.MSTORE,
237264
(byte)Instruction.PUSH1, 0x20, (byte)Instruction.PUSH1, 0x00, (byte)Instruction.RETURN]);
@@ -254,12 +281,15 @@ private ExecutionCapture RunFor(byte[] code, bool useStream, ulong gasLimit)
254281
{
255282
StreamInterpreter.BuildThreshold = 1;
256283
CodeInfo codeInfo = CodeInfoRepository.GetCachedCodeInfo(Recipient, Spec);
257-
if (!System.Threading.SpinWait.SpinUntil(() => codeInfo.GetOrBuildStream() is not null, TimeSpan.FromSeconds(5)))
258-
Assert.Fail("the stream did not build within the timeout");
284+
if (!SpinWait.SpinUntil(() => codeInfo.GetOrBuildStream() is not null, TimeSpan.FromSeconds(5)))
285+
Assert.Fail($"the stream did not build within the timeout for code 0x{Convert.ToHexString(code)}");
259286
}
260287

288+
long framesBefore = StreamInterpreter.FramesExecuted;
261289
ExecutionCapture tracer = new();
262290
_processor.Execute(transaction, new BlockExecutionContext(block.Header, SpecProvider.GetSpec(block.Header)), tracer);
291+
if (useStream && StreamInterpreter.FramesExecuted == framesBefore)
292+
Assert.Fail($"the stream did not engage, so this comparison proved nothing, for code 0x{Convert.ToHexString(code)}");
263293
return tracer;
264294
}
265295
finally

0 commit comments

Comments
 (0)