Skip to content

Conversation

@dbanks12
Copy link
Contributor

@dbanks12 dbanks12 commented Dec 9, 2025

AVM Opcode Spammer

Overview

The Opcode Spammer is a bytecode-building framework for spamming AVM opcodes. It generates bytecode that repeatedly executes target opcodes until the transaction runs out of gas, allowing us to measure worst-case simulation and proving times, and confirm that all such transactions are simulatable and provable.

What's left after this

  1. Spammer tests for external (regular/static) calls/returns/reverts, internalcalls, min/max scaling factors for dynamic gas opcodes, all wire formats (currently only doing smallest wire formats)
  2. Scripts to run proving benchmarks with dedicated resources (and maybe only a subset of the cases)
  3. Use random numbers (preferably seeded) instead of consts like 42
  4. Consider using calldatacopy for "unique" values (for EMITNULLIFIER and SSTORE) (one big cdc instead of 1 add per target instr)
  5. Make the tests for the following more meaningful:
    • CALLDATACOPY
    • RETURNDATACOPY
    • RETURNDATASIZE
    • GETENVVAR
    • GETCONTRACTINSTANCE

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        SPAM_CONFIGS                             │
│  Record<Opcode, SpamConfig[]>                                   │
│                                                                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐              │
│  │   ADD_8     │  │  POSEIDON2  │  │EMITNULLIFIER│  ...         │
│  │ [7 configs] │  │ [1 config]  │  │ [1 config]  │              │
│  │ (per type)  │  │             │  │ (limit=63)  │              │
│  └─────────────┘  └─────────────┘  └─────────────┘              │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                   getSpamConfigsPerOpcode()                     │
│  Returns { opcodes, config[] } for test iteration               │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    testOpcodeSpamCase()                         │
│  Routes to appropriate bytecode generator & executes test       │
│                                                                 │
│  config.limit === undefined?                                    │
│      YES → testStandardOpcodeSpam()                             │
│      NO  → testSideEffectOpcodeSpam()                           │
└─────────────────────────────────────────────────────────────────┘

Two Execution Strategies

Strategy 1: Standard Opcodes (Gas-Limited)

For opcodes without per-TX limits (arithmetic, comparisons, memory ops, etc.), we create a single contract with an infinite loop:

┌────────────────────────────────────────────────────────────────┐
│                    SINGLE CONTRACT                             │
│                                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ SETUP PHASE                                              │  │
│  │  SET mem[0] = initial_value                              │  │
│  │  SET mem[1] = operand                                    │  │
│  │  ...                                                     │  │
│  └──────────────────────────────────────────────────────────┘  │
│                           │                                    │
│                           ▼                                    │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ LOOP (fills remaining bytecode space)          ◄─────┐   │  │
│  │  TARGET_OPCODE  ─┐                                   │   │  │
│  │  TARGET_OPCODE   │ unrolled N times                  │   │  │
│  │  TARGET_OPCODE   │ (N = available_bytes / instr_size)│   │  │
│  │  ...            ─┘                                   │   │  │
│  │  JUMP back ──────────────────────────────────────────┘   │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                │
│  Executes until: OUT OF GAS                                    │
└────────────────────────────────────────────────────────────────┘

Bytecode Layout:

┌─────────────────────────────────────────────────────────────────┐
│ 0x00: SET instructions (setup)                                  │
│ ...                                                             │
│ 0xNN: ┌─── LOOP START ◄──────────────────────────────────────┐  │
│       │ TARGET_OPCODE                                        │  │
│       │ TARGET_OPCODE  (unrolled to fill max bytecode size)  │  │
│       │ TARGET_OPCODE                                        │  │
│       │ ...                                                  │  │
│       └─► JUMP 0xNN ─────────────────────────────────────────┘  │
│ MAX_BYTECODE_BYTES                                              │
└─────────────────────────────────────────────────────────────────┘

Strategy 2: Side-Effect Limited Opcodes (Nested Call Pattern)

For opcodes with per-TX limits (EMITNOTEHASH, EMITNULLIFIER, SENDL2TOL1MSG, etc.), we use a two-contract pattern where the inner contract executes side effects up to the limit, then REVERTs to discard them:

┌─────────────────────────────────────────────────────────────────┐
│                      OUTER CONTRACT                             │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ SETUP                                                     │  │
│  │  CALLDATACOPY inner_address from calldata[0]              │  │
│  │  SET l2Gas = MAX_UINT32                                   │  │
│  │  SET daGas = MAX_UINT32                                   │  │
│  └───────────────────────────────────────────────────────────┘  │
│                           │                                     │
│                           ▼                                     │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ LOOP                                               ◄────┐ │  │
│  │  CALL inner_contract ──────────────────────┐            │ │  │
│  │  JUMP back ─────────────────────────────────────────────┘ │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                               │                 │
│  Executes until: OUT OF GAS                   │                 │
└───────────────────────────────────────────────│─────────────────┘
                                                │
                                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                      INNER CONTRACT                             │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ SETUP                                                     │  │
│  │  SET initial values for side-effect opcode                │  │
│  └───────────────────────────────────────────────────────────┘  │
│                           │                                     │
│                           ▼                                     │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ BODY (unrolled, NOT a loop)                               │  │
│  │  SIDE_EFFECT_OPCODE  ─┐                                   │  │
│  │  SIDE_EFFECT_OPCODE   │ repeated `limit` times            │  │
│  │  SIDE_EFFECT_OPCODE   │ (e.g., 64 for EMITNOTEHASH)       │  │
│  │  ...                 ─┘                                   │  │
│  └───────────────────────────────────────────────────────────┘  │
│                           │                                     │
│                           ▼                                     │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ CLEANUP                                                   │  │
│  │  REVERT (discards all side effects from this call)        │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Why this pattern?

Side-effect opcodes have per-TX limits:

  • EMITNOTEHASH: max 64 per TX
  • EMITNULLIFIER: max 63 per TX (one reserved for TX nullifier)
  • SENDL2TOL1MSG: max 8 per TX
  • EMITUNENCRYPTEDLOG: limited by total log payload size

By having the inner contract REVERT after emitting side effects, those effects are discarded, allowing the outer contract to call it again. This enables thousands of opcode executions per TX instead of just the limit.

SpamConfig Structure

interface SpamConfig {
  // Memory cells to initialize before the loop
  setup: SetupItem[];

  // Factory to create target instruction(s) to spam
  targetInstructions: () => Bufferable[];

  // Instructions to run after target spam (for side-effect opcodes)
  cleanupInstructions?: () => Bufferable[];

  // Per-TX limit (triggers nested call pattern if set)
  limit?: number;

  // Label for test display (e.g., "UINT32" for type variants)
  label?: string;
}

Type Variants

Many opcodes support multiple types. The spammer tests each type variant separately:

// ADD_8 has 7 configs - one per supported type
[Opcode.ADD_8]: ALL_TAGS.map(tag => ({
  label: TypeTag[tag],  // "FIELD", "UINT1", "UINT8", etc.
  setup: [
    { offset: 0, value: withTag(1n, tag) },
    { offset: 1, value: withTag(1n, tag) },
  ],
  targetInstructions: () => [new Add(0, 0, 1, 0).as(Opcode.ADD_8, Add.wireFormat8)],
})),

Test output hierarchy:

ADD_8
  ├── ADD_8/FIELD     ✓
  ├── ADD_8/UINT1     ✓
  ├── ADD_8/UINT8     ✓
  ├── ADD_8/UINT16    ✓
  ├── ADD_8/UINT32    ✓
  ├── ADD_8/UINT64    ✓
  └── ADD_8/UINT128   ✓

In Tests

import { getSpamConfigsPerOpcode, testOpcodeSpamCase } from '@aztec/simulator/public/fixtures';

const groupedSpamConfigs = getSpamConfigsPerOpcode();

describe.each(groupedSpamConfigs)('$opcode', ({ configs }) => {
  it.each(configs)('$label', async config => {
    await testOpcodeSpamCase(tester, config, expectToBeTrue);
  });
});

Test Suites

Simulation Benchmarks

yarn-project/simulator/src/public/public_tx_simulator/apps_tests/opcode_spam.test.ts

Runs opcode spam through the C++ simulator (and optionally TS vs C++ comparison).

Proving Benchmarks

yarn-project/bb-prover/src/avm_proving_tests/avm_opcode_spam.test.ts

Runs opcode spam through full AVM proving. Skipped in CI (meant for local measurement).

Copy link
Contributor Author

dbanks12 commented Dec 9, 2025

This stack of pull requests is managed by Graphite. Learn more about stacking.

@dbanks12 dbanks12 force-pushed the db/opcode-spammer-v3 branch 14 times, most recently from 254bdd6 to 877b465 Compare December 15, 2025 16:09
Comment on lines 238 to 255
const fnSelector = await getFunctionSelector(call.fnName, contractArtifact);
const fnAbi = getContractFunctionAbi(call.fnName, contractArtifact)!;
const encodedArgs = encodeArguments(fnAbi, call.args);
const calldata = [fnSelector.toField(), ...encodedArgs];
let calldata: Fr[] = [];
if (!call.fnName) {
this.logger.warn(
`No function name specified for call to contract ${call.address.toString()}. Assuming this is a custom bytecode with no public_dispatch function.`,
);
this.logger.warn(`Not using ABI to encode arguments. Not prepending fn selector to calldata.`);
try {
calldata = call.args.map(arg => new Fr(arg));
} catch (error) {
this.logger.warn(`Tried assuming that all arguments are Field-like. Failed. Error: ${error}`);
throw error;
}
} else {
const fnSelector = await getFunctionSelector(call.fnName, contractArtifact);
const fnAbi = getContractFunctionAbi(call.fnName, contractArtifact)!;
const encodedArgs = encodeArguments(fnAbi, call.args);
calldata = [fnSelector.toField(), ...encodedArgs];
}
Copy link
Contributor Author

@dbanks12 dbanks12 Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tester was dying if fnName wasn't there. And it shouldn't need it!

@dbanks12 dbanks12 force-pushed the db/opcode-spammer-v3 branch 2 times, most recently from 68f780c to ded0bc8 Compare December 15, 2025 16:35
@dbanks12 dbanks12 marked this pull request as ready for review December 15, 2025 16:38
@dbanks12 dbanks12 force-pushed the db/opcode-spammer-v3 branch 2 times, most recently from 3877161 to c576708 Compare December 15, 2025 16:46
@dbanks12 dbanks12 force-pushed the db/opcode-spammer-v3 branch 2 times, most recently from e3c62c6 to d639418 Compare December 15, 2025 16:51
@dbanks12 dbanks12 force-pushed the db/opcode-spammer-v3 branch from d639418 to 829981d Compare December 15, 2025 16:52
@dbanks12 dbanks12 changed the title test: AVM opcode spammer - attempt 3 test: AVM opcode spammer Dec 15, 2025
Comment on lines -16 to +21
const selector = FunctionSelector.fromField(calldata[0]);
return (await db.getDebugFunctionName(contractAddress, selector)) ?? selector.toString();
const fallbackName = `<calldata[0]:${calldata[0].toString()}> (Contract Address: ${contractAddress})`;
const selector = FunctionSelector.fromFieldOrUndefined(calldata[0]);
if (!selector) {
return fallbackName;
}
return (await db.getDebugFunctionName(contractAddress, selector)) ?? fallbackName;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was exploding if calldata[0] was there but couldn't be interpreted as a function selector (4 bytes)

/*globals=*/ undefined, // default
/*metrics=*/ undefined,
/*useCppSimulator=*/ true,
simConfig,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this using TS before? How was it working?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was using CppVsTs (sim via both and compare). And it was working because the CppVsTs simulator returns TS simulation's results which include hints and PIs.

This PR changes CppVsTs simulator to return C++'s results instead, so this broke until I specified collectHints and PIs in this config. Now that CppVsTs returns C++'s results, it respects the sim config

const COLLECT_META_CHECK_RET = true;
const expectToBeTrue = COLLECT_META_CHECK_RET ? (x: boolean) => expect(x).toBe(true) : () => {};

describe('Opcode Spammer Benchmarks', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we disable this in CI as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could! They actually found a few bugs in the simulators 😢 But I think we can disable at least until we can run them more rapidly (opcode gas tweaks is a prereq to that)

await worldStateService.close();
});

describe.each(groupedSpamConfigs)('$opcode', ({ configs }) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think jest runs tests in parallel by default. You might want to add a comment to run this with

HARDWARE_CONCURRENCY=32 yarn test .... -- --runInBand

which i think would run them serially

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I think jest runs all tests in 1 file serially. It runs different test files in parallel

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll double check

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Tests within 1 files are all run serially by jest. It parallelizes runs of distinct test files

Copy link
Contributor

@fcarreiro fcarreiro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it was not clear from the comments: I think the worst cases are good for simulation, but cannot be trusted for proving due to deduplication.

@dbanks12 dbanks12 force-pushed the db/opcode-spammer-v3 branch 2 times, most recently from 0185ab0 to 58b0297 Compare December 15, 2025 20:26
@dbanks12 dbanks12 force-pushed the db/opcode-spammer-v3 branch 2 times, most recently from 1239e75 to e6eec10 Compare December 15, 2025 21:33
@dbanks12 dbanks12 force-pushed the db/opcode-spammer-v3 branch from e6eec10 to e6a8f44 Compare December 15, 2025 21:42
@dbanks12 dbanks12 merged commit 2e114da into merge-train/avm Dec 15, 2025
8 checks passed
@dbanks12 dbanks12 deleted the db/opcode-spammer-v3 branch December 15, 2025 22:01
ludamad pushed a commit that referenced this pull request Dec 19, 2025
# AVM Opcode Spammer

## Overview

The **Opcode Spammer** is a bytecode-building framework for spamming AVM
opcodes. It generates bytecode that repeatedly executes target opcodes
until the transaction runs out of gas, allowing us to measure worst-case
simulation and proving times, and confirm that all such transactions are
simulatable and provable.

## What's left after this

1. Spammer tests for external (regular/static) calls/returns/reverts,
internalcalls, min/max scaling factors for dynamic gas opcodes, all wire
formats (currently only doing smallest wire formats)
2. Scripts to run proving benchmarks with dedicated resources (and maybe
only a subset of the cases)
4. Use random numbers (preferably seeded) instead of consts like 42
5. Consider using calldatacopy for "unique" values (for EMITNULLIFIER
and SSTORE) (one big cdc instead of 1 add per target instr)
7. Make the tests for the following more meaningful:
    - CALLDATACOPY
    - RETURNDATACOPY
    - RETURNDATASIZE
    - GETENVVAR
    - GETCONTRACTINSTANCE

## Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│                        SPAM_CONFIGS                             │
│  Record<Opcode, SpamConfig[]>                                   │
│                                                                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐              │
│  │   ADD_8     │  │  POSEIDON2  │  │EMITNULLIFIER│  ...         │
│  │ [7 configs] │  │ [1 config]  │  │ [1 config]  │              │
│  │ (per type)  │  │             │  │ (limit=63)  │              │
│  └─────────────┘  └─────────────┘  └─────────────┘              │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                   getSpamConfigsPerOpcode()                     │
│  Returns { opcodes, config[] } for test iteration               │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    testOpcodeSpamCase()                         │
│  Routes to appropriate bytecode generator & executes test       │
│                                                                 │
│  config.limit === undefined?                                    │
│      YES → testStandardOpcodeSpam()                             │
│      NO  → testSideEffectOpcodeSpam()                           │
└─────────────────────────────────────────────────────────────────┘
```

## Two Execution Strategies

### Strategy 1: Standard Opcodes (Gas-Limited)

For opcodes without per-TX limits (arithmetic, comparisons, memory ops,
etc.), we create a single contract with an infinite loop:

```
┌────────────────────────────────────────────────────────────────┐
│                    SINGLE CONTRACT                             │
│                                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ SETUP PHASE                                              │  │
│  │  SET mem[0] = initial_value                              │  │
│  │  SET mem[1] = operand                                    │  │
│  │  ...                                                     │  │
│  └──────────────────────────────────────────────────────────┘  │
│                           │                                    │
│                           ▼                                    │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ LOOP (fills remaining bytecode space)          ◄─────┐   │  │
│  │  TARGET_OPCODE  ─┐                                   │   │  │
│  │  TARGET_OPCODE   │ unrolled N times                  │   │  │
│  │  TARGET_OPCODE   │ (N = available_bytes / instr_size)│   │  │
│  │  ...            ─┘                                   │   │  │
│  │  JUMP back ──────────────────────────────────────────┘   │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                │
│  Executes until: OUT OF GAS                                    │
└────────────────────────────────────────────────────────────────┘
```

**Bytecode Layout:**
```
┌─────────────────────────────────────────────────────────────────┐
│ 0x00: SET instructions (setup)                                  │
│ ...                                                             │
│ 0xNN: ┌─── LOOP START ◄──────────────────────────────────────┐  │
│       │ TARGET_OPCODE                                        │  │
│       │ TARGET_OPCODE  (unrolled to fill max bytecode size)  │  │
│       │ TARGET_OPCODE                                        │  │
│       │ ...                                                  │  │
│       └─► JUMP 0xNN ─────────────────────────────────────────┘  │
│ MAX_BYTECODE_BYTES                                              │
└─────────────────────────────────────────────────────────────────┘
```

### Strategy 2: Side-Effect Limited Opcodes (Nested Call Pattern)

For opcodes with per-TX limits (EMITNOTEHASH, EMITNULLIFIER,
SENDL2TOL1MSG, etc.), we use a two-contract pattern where the inner
contract executes side effects up to the limit, then REVERTs to discard
them:

```
┌─────────────────────────────────────────────────────────────────┐
│                      OUTER CONTRACT                             │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ SETUP                                                     │  │
│  │  CALLDATACOPY inner_address from calldata[0]              │  │
│  │  SET l2Gas = MAX_UINT32                                   │  │
│  │  SET daGas = MAX_UINT32                                   │  │
│  └───────────────────────────────────────────────────────────┘  │
│                           │                                     │
│                           ▼                                     │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ LOOP                                               ◄────┐ │  │
│  │  CALL inner_contract ──────────────────────┐            │ │  │
│  │  JUMP back ─────────────────────────────────────────────┘ │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                               │                 │
│  Executes until: OUT OF GAS                   │                 │
└───────────────────────────────────────────────│─────────────────┘
                                                │
                                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                      INNER CONTRACT                             │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ SETUP                                                     │  │
│  │  SET initial values for side-effect opcode                │  │
│  └───────────────────────────────────────────────────────────┘  │
│                           │                                     │
│                           ▼                                     │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ BODY (unrolled, NOT a loop)                               │  │
│  │  SIDE_EFFECT_OPCODE  ─┐                                   │  │
│  │  SIDE_EFFECT_OPCODE   │ repeated `limit` times            │  │
│  │  SIDE_EFFECT_OPCODE   │ (e.g., 64 for EMITNOTEHASH)       │  │
│  │  ...                 ─┘                                   │  │
│  └───────────────────────────────────────────────────────────┘  │
│                           │                                     │
│                           ▼                                     │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ CLEANUP                                                   │  │
│  │  REVERT (discards all side effects from this call)        │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
```

**Why this pattern?**

Side-effect opcodes have per-TX limits:
- `EMITNOTEHASH`: max 64 per TX
- `EMITNULLIFIER`: max 63 per TX (one reserved for TX nullifier)
- `SENDL2TOL1MSG`: max 8 per TX
- `EMITUNENCRYPTEDLOG`: limited by total log payload size

By having the inner contract REVERT after emitting side effects, those
effects are discarded, allowing the outer contract to call it again.
This enables thousands of opcode executions per TX instead of just the
limit.

## SpamConfig Structure

```typescript
interface SpamConfig {
  // Memory cells to initialize before the loop
  setup: SetupItem[];

  // Factory to create target instruction(s) to spam
  targetInstructions: () => Bufferable[];

  // Instructions to run after target spam (for side-effect opcodes)
  cleanupInstructions?: () => Bufferable[];

  // Per-TX limit (triggers nested call pattern if set)
  limit?: number;

  // Label for test display (e.g., "UINT32" for type variants)
  label?: string;
}
```

## Type Variants

Many opcodes support multiple types. The spammer tests each type variant
separately:

```typescript
// ADD_8 has 7 configs - one per supported type
[Opcode.ADD_8]: ALL_TAGS.map(tag => ({
  label: TypeTag[tag],  // "FIELD", "UINT1", "UINT8", etc.
  setup: [
    { offset: 0, value: withTag(1n, tag) },
    { offset: 1, value: withTag(1n, tag) },
  ],
  targetInstructions: () => [new Add(0, 0, 1, 0).as(Opcode.ADD_8, Add.wireFormat8)],
})),
```

Test output hierarchy:
```
ADD_8
  ├── ADD_8/FIELD     ✓
  ├── ADD_8/UINT1     ✓
  ├── ADD_8/UINT8     ✓
  ├── ADD_8/UINT16    ✓
  ├── ADD_8/UINT32    ✓
  ├── ADD_8/UINT64    ✓
  └── ADD_8/UINT128   ✓
```

### In Tests

```typescript
import { getSpamConfigsPerOpcode, testOpcodeSpamCase } from '@aztec/simulator/public/fixtures';

const groupedSpamConfigs = getSpamConfigsPerOpcode();

describe.each(groupedSpamConfigs)('$opcode', ({ configs }) => {
  it.each(configs)('$label', async config => {
    await testOpcodeSpamCase(tester, config, expectToBeTrue);
  });
});
```

## Test Suites

### Simulation Benchmarks

`yarn-project/simulator/src/public/public_tx_simulator/apps_tests/opcode_spam.test.ts`

Runs opcode spam through the C++ simulator (and optionally TS vs C++
comparison).

### Proving Benchmarks
`yarn-project/bb-prover/src/avm_proving_tests/avm_opcode_spam.test.ts`

Runs opcode spam through full AVM proving. Skipped in CI (meant for
local measurement).
ludamad pushed a commit that referenced this pull request Dec 19, 2025
# AVM Opcode Spammer

## Overview

The **Opcode Spammer** is a bytecode-building framework for spamming AVM
opcodes. It generates bytecode that repeatedly executes target opcodes
until the transaction runs out of gas, allowing us to measure worst-case
simulation and proving times, and confirm that all such transactions are
simulatable and provable.

## What's left after this

1. Spammer tests for external (regular/static) calls/returns/reverts,
internalcalls, min/max scaling factors for dynamic gas opcodes, all wire
formats (currently only doing smallest wire formats)
2. Scripts to run proving benchmarks with dedicated resources (and maybe
only a subset of the cases)
4. Use random numbers (preferably seeded) instead of consts like 42
5. Consider using calldatacopy for "unique" values (for EMITNULLIFIER
and SSTORE) (one big cdc instead of 1 add per target instr)
7. Make the tests for the following more meaningful:
    - CALLDATACOPY
    - RETURNDATACOPY
    - RETURNDATASIZE
    - GETENVVAR
    - GETCONTRACTINSTANCE

## Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│                        SPAM_CONFIGS                             │
│  Record<Opcode, SpamConfig[]>                                   │
│                                                                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐              │
│  │   ADD_8     │  │  POSEIDON2  │  │EMITNULLIFIER│  ...         │
│  │ [7 configs] │  │ [1 config]  │  │ [1 config]  │              │
│  │ (per type)  │  │             │  │ (limit=63)  │              │
│  └─────────────┘  └─────────────┘  └─────────────┘              │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                   getSpamConfigsPerOpcode()                     │
│  Returns { opcodes, config[] } for test iteration               │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    testOpcodeSpamCase()                         │
│  Routes to appropriate bytecode generator & executes test       │
│                                                                 │
│  config.limit === undefined?                                    │
│      YES → testStandardOpcodeSpam()                             │
│      NO  → testSideEffectOpcodeSpam()                           │
└─────────────────────────────────────────────────────────────────┘
```

## Two Execution Strategies

### Strategy 1: Standard Opcodes (Gas-Limited)

For opcodes without per-TX limits (arithmetic, comparisons, memory ops,
etc.), we create a single contract with an infinite loop:

```
┌────────────────────────────────────────────────────────────────┐
│                    SINGLE CONTRACT                             │
│                                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ SETUP PHASE                                              │  │
│  │  SET mem[0] = initial_value                              │  │
│  │  SET mem[1] = operand                                    │  │
│  │  ...                                                     │  │
│  └──────────────────────────────────────────────────────────┘  │
│                           │                                    │
│                           ▼                                    │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ LOOP (fills remaining bytecode space)          ◄─────┐   │  │
│  │  TARGET_OPCODE  ─┐                                   │   │  │
│  │  TARGET_OPCODE   │ unrolled N times                  │   │  │
│  │  TARGET_OPCODE   │ (N = available_bytes / instr_size)│   │  │
│  │  ...            ─┘                                   │   │  │
│  │  JUMP back ──────────────────────────────────────────┘   │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                │
│  Executes until: OUT OF GAS                                    │
└────────────────────────────────────────────────────────────────┘
```

**Bytecode Layout:**
```
┌─────────────────────────────────────────────────────────────────┐
│ 0x00: SET instructions (setup)                                  │
│ ...                                                             │
│ 0xNN: ┌─── LOOP START ◄──────────────────────────────────────┐  │
│       │ TARGET_OPCODE                                        │  │
│       │ TARGET_OPCODE  (unrolled to fill max bytecode size)  │  │
│       │ TARGET_OPCODE                                        │  │
│       │ ...                                                  │  │
│       └─► JUMP 0xNN ─────────────────────────────────────────┘  │
│ MAX_BYTECODE_BYTES                                              │
└─────────────────────────────────────────────────────────────────┘
```

### Strategy 2: Side-Effect Limited Opcodes (Nested Call Pattern)

For opcodes with per-TX limits (EMITNOTEHASH, EMITNULLIFIER,
SENDL2TOL1MSG, etc.), we use a two-contract pattern where the inner
contract executes side effects up to the limit, then REVERTs to discard
them:

```
┌─────────────────────────────────────────────────────────────────┐
│                      OUTER CONTRACT                             │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ SETUP                                                     │  │
│  │  CALLDATACOPY inner_address from calldata[0]              │  │
│  │  SET l2Gas = MAX_UINT32                                   │  │
│  │  SET daGas = MAX_UINT32                                   │  │
│  └───────────────────────────────────────────────────────────┘  │
│                           │                                     │
│                           ▼                                     │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ LOOP                                               ◄────┐ │  │
│  │  CALL inner_contract ──────────────────────┐            │ │  │
│  │  JUMP back ─────────────────────────────────────────────┘ │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                               │                 │
│  Executes until: OUT OF GAS                   │                 │
└───────────────────────────────────────────────│─────────────────┘
                                                │
                                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                      INNER CONTRACT                             │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ SETUP                                                     │  │
│  │  SET initial values for side-effect opcode                │  │
│  └───────────────────────────────────────────────────────────┘  │
│                           │                                     │
│                           ▼                                     │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ BODY (unrolled, NOT a loop)                               │  │
│  │  SIDE_EFFECT_OPCODE  ─┐                                   │  │
│  │  SIDE_EFFECT_OPCODE   │ repeated `limit` times            │  │
│  │  SIDE_EFFECT_OPCODE   │ (e.g., 64 for EMITNOTEHASH)       │  │
│  │  ...                 ─┘                                   │  │
│  └───────────────────────────────────────────────────────────┘  │
│                           │                                     │
│                           ▼                                     │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ CLEANUP                                                   │  │
│  │  REVERT (discards all side effects from this call)        │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
```

**Why this pattern?**

Side-effect opcodes have per-TX limits:
- `EMITNOTEHASH`: max 64 per TX
- `EMITNULLIFIER`: max 63 per TX (one reserved for TX nullifier)
- `SENDL2TOL1MSG`: max 8 per TX
- `EMITUNENCRYPTEDLOG`: limited by total log payload size

By having the inner contract REVERT after emitting side effects, those
effects are discarded, allowing the outer contract to call it again.
This enables thousands of opcode executions per TX instead of just the
limit.

## SpamConfig Structure

```typescript
interface SpamConfig {
  // Memory cells to initialize before the loop
  setup: SetupItem[];

  // Factory to create target instruction(s) to spam
  targetInstructions: () => Bufferable[];

  // Instructions to run after target spam (for side-effect opcodes)
  cleanupInstructions?: () => Bufferable[];

  // Per-TX limit (triggers nested call pattern if set)
  limit?: number;

  // Label for test display (e.g., "UINT32" for type variants)
  label?: string;
}
```

## Type Variants

Many opcodes support multiple types. The spammer tests each type variant
separately:

```typescript
// ADD_8 has 7 configs - one per supported type
[Opcode.ADD_8]: ALL_TAGS.map(tag => ({
  label: TypeTag[tag],  // "FIELD", "UINT1", "UINT8", etc.
  setup: [
    { offset: 0, value: withTag(1n, tag) },
    { offset: 1, value: withTag(1n, tag) },
  ],
  targetInstructions: () => [new Add(0, 0, 1, 0).as(Opcode.ADD_8, Add.wireFormat8)],
})),
```

Test output hierarchy:
```
ADD_8
  ├── ADD_8/FIELD     ✓
  ├── ADD_8/UINT1     ✓
  ├── ADD_8/UINT8     ✓
  ├── ADD_8/UINT16    ✓
  ├── ADD_8/UINT32    ✓
  ├── ADD_8/UINT64    ✓
  └── ADD_8/UINT128   ✓
```

### In Tests

```typescript
import { getSpamConfigsPerOpcode, testOpcodeSpamCase } from '@aztec/simulator/public/fixtures';

const groupedSpamConfigs = getSpamConfigsPerOpcode();

describe.each(groupedSpamConfigs)('$opcode', ({ configs }) => {
  it.each(configs)('$label', async config => {
    await testOpcodeSpamCase(tester, config, expectToBeTrue);
  });
});
```

## Test Suites

### Simulation Benchmarks

`yarn-project/simulator/src/public/public_tx_simulator/apps_tests/opcode_spam.test.ts`

Runs opcode spam through the C++ simulator (and optionally TS vs C++
comparison).

### Proving Benchmarks
`yarn-project/bb-prover/src/avm_proving_tests/avm_opcode_spam.test.ts`

Runs opcode spam through full AVM proving. Skipped in CI (meant for
local measurement).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants