zk-scribble
Trace mutation fuzzer for Hekate ZK programs and chiplets.
Tampers your valid trace, runs preflight checks, panics if the tamper goes undetected. If scribble panics, your constraints have a hole.
What it does
- Flips bits, swaps rows, toggles selectors, injects out-of-range values
- Validates using preflight (row-by-row constraint eval + bus multiset check)
- Proptest integration, shrinks failures to the smallest mutation that escapes your AIR
- Runs in debug mode in seconds, not minutes
What it doesn't do
- No proofs. Scribble never calls the prover or verifier. It checks your constraints on the concrete trace, not through the ZK pipeline. This is why it's fast.
- No protocol-level testing. Transcript binding, Fiat-Shamir, evaluation arguments, Brakedown, those need the real prover/verifier e2e tests.
- No soundness guarantees. Passing scribble means every random mutation was caught. It doesn't prove your constraints are complete, only that the ones you wrote are wired up.
Two layers
Layer 1: random coverage. assert_all_caught with shallow mutations (BitFlip, FlipSelector, SwapRows,
OutOfBounds). Finds unconstrained columns, missing booleans, selector isolation gaps. Proptest shrinks any escape
to a minimal reproduction. Run during chiplet development.
Layer 2: deterministic emulation. check_single_mutation with hand-crafted structural or compound mutations
(SwapColumns, CopyColumns, Compound). Replaces the 80-line boilerplate of each e2e exploit test with a 15-line
call. Same attack logic, 100x faster (preflight vs prove+verify). Run as regression suite.
Quick start
use ;
Target a specific chiplet
use ;
Restrict mutation kinds
use ;
Dispatch swap (Layer 2)
Swap a subset of columns between two rows. Selectors and RAM columns stay intact, emulates an attacker who rearranges data while preserving dispatch structure.
use ;
Coordinated cross-trace attack
Compound applies multiple mutations atomically. Modify chiplet AND main trace
in sync to bypass per-table checks, the cross-table bus is the only backstop.
use ;
Reading the preflight report
When preflight catches a mutation, the report names the failing invariant, table, and row:
PREFLIGHT: 1 constraint violations, 0 boundary violations, 0 bus issues
[Chiplet 0] Constraint 32 "bits_01_equal" failed at row 0
Boundary violations show the concrete mismatch:
PREFLIGHT: 0 constraint violations, 2 boundary violations, 0 bus issues
Boundary #0: col=0 row=0 actual=Flat(Block128(0)) expected=Flat(Block128(22067681354706156661646625971774519825))
Boundary #1: col=1 row=0 actual=Flat(Block128(1)) expected=Flat(Block128(265498766201044366875656389800751278795))
Bus diagnostics list every endpoint with its row count, active rows, and multiset product, so you can see which side of the bus diverged:
PREFLIGHT: 0 constraint violations, 0 boundary violations, 1 bus issues
Bus "test_bus" (2 endpoints):
Main: 8 rows, 4 active, product=Flat(Block128(16200159481073039905153729824056248498))
Chiplet 0: 8 rows, 4 active, product=Flat(Block128(179105452667142969769264969604701840821))
If a mutation escapes (report is clean), scribble panics with the proptest-shrunk Mutation. That tamper
is the soundness gap, add a constraint that catches it.
Requirements
Depends on hekate-math, hekate-sdk, hekate-core, hekate-program.
License
MIT