Synthesizer: Class Structure
This document provides detailed information about Synthesizer's class structure, relationships, and implementation details.
Synthesizer Class Hierarchy
┌─────────────────────────────────────────────────────────┐
│ Synthesizer │
│ ┌────────────────────────────────────────────────────┐ │
│ │ StateManager │ │
│ │ - placements: Map<number, PlacementEntry> │ │
│ │ - auxin: Auxin (auxiliary inputs) │ │
│ │ - storagePt, logPt, keccakPt, etc. │ │
│ │ - placementIndex: number │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ OperationHandler │ │
│ │ - placeArith(op, inputs) │ │
│ │ - placeExp(base, exponent) │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ DataLoader │ │
│ │ - loadStorage(addr, key) │ │
│ │ - storeStorage(addr, key, value) │ │
│ │ - loadEnvInf/loadBlkInf │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ MemoryManager │ │
│ │ - placeMemoryToStack(aliasInfos) │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ BufferManager │ │
│ │ - addWireToInBuffer(val, placementId) │ │
│ │ - addWireToOutBuffer(sym, val, placementId) │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Finalizer │ │
│ │ - refactor(): optimize placements │ │
│ │ - buildPermutation(): wire connections │ │
│ │ - outputFiles(): permutation.json, witness │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Detailed Class Breakdown
1. Synthesizer Class
Location: src/tokamak/core/synthesizer/index.ts:27-181
Role: Central coordinator using Facade pattern
Architecture:
export class Synthesizer
implements ISynthesizerProvider, IDataLoaderProvider, IMemoryManagerProvider
{
private _state: StateManager; // Line 30
private operationHandler: OperationHandler; // Line 31
private dataLoader: DataLoader; // Line 32
private memoryManager: MemoryManager; // Line 33
private bufferManager: BufferManager; // Line 34
constructor() {
this._state = new StateManager();
this.operationHandler = new OperationHandler(this, this._state);
this.dataLoader = new DataLoader(this, this._state);
this.memoryManager = new MemoryManager(this, this._state);
this.bufferManager = new BufferManager(this, this._state);
}
public get state(): StateManager {
return this._state;
}
// Delegate to handlers
public placeArith(name: ArithmeticOperator, inPts: DataPt[]): DataPt[] {
return this.operationHandler.placeArith(name, inPts);
}
public loadStorage(codeAddress: string, key: bigint, value: bigint): DataPt {
return this.dataLoader.loadStorage(codeAddress, key, value);
}
// ... more delegation methods
}
Design: Facade pattern delegates to specialized handlers
2. StateManager Class
Location: src/tokamak/core/handlers/stateManager.ts:24-102
Role: Central state repository
Key Data Structures:
export class StateManager {
public placements!: Placements; // All placement instances
public auxin!: Auxin; // Auxiliary inputs
public envInf!: Map<string, {...}>; // Environment info (CALLER, etc.)
public blkInf!: Map<string, {...}>; // Block info (NUMBER, etc.)
public storagePt!: Map<string, DataPt>; // Storage symbols
public logPt!: {...}[]; // Log data
public keccakPt!: {...}[]; // Keccak inputs/outputs
public TStoragePt!: Map<...>; // Transient storage
public placementIndex!: number; // Sequential counter
public subcircuitInfoByName!: SubcircuitInfoByName;
public subcircuitNames!: SubcircuitNames[];
constructor() {
this._initializeState(); // Reset all state
this._initializeSubcircuitInfo(); // Load subcircuit metadata
this._initializePlacements(); // Create buffer placements (0-3)
this.placementIndex = INITIAL_PLACEMENT_INDEX; // Start from 4
}
public getNextPlacementIndex(): number {
return this.placementIndex++; // Atomic increment
}
}
Key Points:
- Single source of truth for all Synthesizer state
- Placements 0-3 reserved for buffers
- Placement IDs start from 4
3. OperationHandler Class
Location: src/tokamak/core/handlers/operationHandler.ts
Role: Create placements for arithmetic/logic operations
Key Method:
public placeArith(name: ArithmeticOperator, inPts: DataPt[]): DataPt[] {
// 1. Map operation to subcircuit
const [subcircuitName, selector] = SUBCIRCUIT_MAPPING[name];
// 2. Create selector DataPt
const selectorPt = DataPointFactory.create({
source: 'literal',
value: selector,
// ...
});
// 3. Create output DataPt
const outPt = DataPointFactory.create({
source: this.state.getNextPlacementIndex(), // New placement ID
wireIndex: outWireIndex,
value: computedValue,
// ...
});
// 4. Call Synthesizer.place()
this.provider.place(
subcircuitName,
[selectorPt, ...inPts],
[outPt],
name
);
return [outPt];
}
4. DataLoader Class
Location: src/tokamak/core/handlers/dataLoader.ts
Role: Handle external data (storage, environment, block info)
Key Methods:
public loadStorage(codeAddress: string, key: bigint, value: bigint): DataPt
public storeStorage(codeAddress: string, key: bigint, inPt: DataPt): void
public loadEnvInf(name: EnvInfNames, value: bigint): DataPt
public loadBlkInf(name: BlkInfNames, value: bigint): DataPt
public storeLog(valPts: DataPt[], topicPts: DataPt[]): void
public loadAndStoreKeccak(inPts: DataPt[], outValue: bigint, length: bigint): DataPt
Example: loadStorage()
public loadStorage(codeAddress: string, key: bigint, value: bigint): DataPt {
const keyString = `${codeAddress}_${key.toString()}`;
// Check if already loaded (warm access)
if (this.state.storagePt.has(keyString)) {
return this.state.storagePt.get(keyString)!;
}
// Cold access: load from PRV_IN buffer
const inPt = DataPointFactory.create({ value, ... });
const outPt = this.provider.addWireToInBuffer(inPt, PRV_IN_PLACEMENT_INDEX);
// Cache for future accesses
this.state.storagePt.set(keyString, outPt);
return outPt;
}
5. MemoryManager Class
Location: src/tokamak/core/handlers/memoryManager.ts
Role: Resolve memory aliasing
Key Method:
public placeMemoryToStack(dataAliasInfos: DataAliasInfos): DataPt {
// Generate subcircuits to reconstruct overlapping memory
// Uses SHR, SHL, AND, OR to combine fragments
// Returns reconstructed symbol
}
Used by: MLOAD, CALLDATACOPY, KECCAK256, LOG, etc.
6. BufferManager Class
Location: src/tokamak/core/handlers/bufferManager.ts
Role: Manage LOAD and RETURN buffer placements
Key Methods:
public addWireToInBuffer(inPt: DataPt, placementId: number): DataPt {
// Placement 0 (PUB_IN) or 2 (PRV_IN)
// External value → Symbol conversion
const outPt = DataPointFactory.create({
source: placementId,
wireIndex: nextIndex,
value: inPt.value,
// ...
});
this.state.placements.get(placementId)!.inPts.push(inPt);
this.state.placements.get(placementId)!.outPts.push(outPt);
return outPt; // Symbol for circuit
}
public addWireToOutBuffer(inPt: DataPt, outPt: DataPt, placementId: number): void {
// Placement 1 (PUB_OUT) or 3 (PRV_OUT)
// Symbol → External value conversion
this.state.placements.get(placementId)!.inPts.push(inPt);
this.state.placements.get(placementId)!.outPts.push(outPt);
}
7. Finalizer Class
Location: src/tokamak/core/finalizer/index.ts:5-26
Role: Transform symbolic execution results into concrete circuit files for the backend prover
Key Points
- Post-execution processor: Runs after transaction execution completes
- EVM-to-Circom conversion: Converts EVM 256-bit words into Circom-compatible 128-bit limbs
- Witness generation: Produces concrete values that will be converted into a proof by the backend
- Permutation analysis: Analyzes chains of symbolic references and generates permutation polynomials
- Backend interface: Produces JSON files that the Rust backend can consume
Three Core Purposes
The Finalizer performs three essential transformations:
Purpose 1: Convert EVM Words to Circom Words
- Problem: EVM uses 256-bit values, but Circom's BLS12-381 scalar field is 254-bit
- Risk: Direct use of 256-bit values can cause field overflow
- Solution: Split each 256-bit value into two 128-bit limbs (lower + upper)
- Trade-off: This splitting increases circuit size and slows down proving time, but it's mathematically necessary
- Note: If we could avoid this conversion, performance would be significantly better
Purpose 2: Generate Circuit Witness
- What it is: Concrete numerical values for every wire in the circuit
- Why needed: The backend prover needs actual values (not symbolic pointers) to compute the proof
- Process: Converts symbolic data (StackPt, MemoryPt) into numerical witness values
- Output: Placement-specific witness files that align with circuit structure
Purpose 3: Analyze Symbol Chains and Generate Permutation
- What it is: A permutation tracks which wires across different placements must have the same value
- Why needed: In zk-SNARKs, when one placement's output feeds into another placement's input, the proof system must verify they're equal
- How it works:
- Analyzes data flow between placements (e.g., ADD's output → MUL's input)
- Groups wires that reference the same value into "permutation groups"
- Generates permutation polynomials (X and Y) that encode these equality constraints
- Mathematical basis: Implements Section 3.1 "Compilers" of the Tokamak zk-SNARK paper
- Output:
permutation.jsoncontaining the circuit structure and wire connection mappings
Execution Flow
export class Finalizer {
private state: StateManager;
constructor(stateManager: StateManager) {
this.state = stateManager;
}
public async exec(_path?: string, writeToFS: boolean = true): Promise<Permutation> {
// 1. Refactor placements (convert EVM words to Circom words)
const placementRefactor = new PlacementRefactor(this.state);
const refactoriedPlacements = placementRefactor.refactor();
// 2. Generate permutation and witness
const permutation = new Permutation(refactoriedPlacements, _path);
permutation.placementVariables = await permutation.outputPlacementVariables(
refactoriedPlacements,
_path,
);
// 3. Write permutation.json
permutation.outputPermutation(_path);
return permutation;
}
}
Three-Step Process in Detail
Step 1: Placement Refactoring (EVM-to-Circom Conversion)
const placementRefactor = new PlacementRefactor(this.state);
const refactoriedPlacements = placementRefactor.refactor();
This step performs two critical transformations:
Remove Unused Wires
- Identifies wires that were created but never used by any placement
- Example: If LOAD buffer creates 10 wires but only 7 are referenced → remove 3 unused wires
- This is a true optimization that reduces circuit size
Split 256-bit Values into 128-bit Limbs (Precision Alignment for Elliptic Curve Fields)
- Why necessary: Circom's BLS12-381 scalar field is 254-bit, but Ethereum uses 256-bit values
- Consequence: This is NOT an optimization—it increases circuit size and slows down proving
- Alternative: If Circom could handle 256-bit values natively, we wouldn't need this step and performance would improve
Implementation (
placementRefactor.ts:53-82):private halveWordSizeOfWires(newDataPts: DataPt[], origDataPt: DataPt): number[] { const newIndex = newDataPts.length; const indLow = newIndex; const indHigh = indLow + 1; if (origDataPt.sourceSize > 16) { // If > 128 bits (16 bytes) // Create two DataPt entries for lower and upper limbs newDataPts[indLow] = { ...origDataPt }; newDataPts[indLow].wireIndex = indLow; newDataPts[indHigh] = { ...origDataPt }; newDataPts[indHigh].wireIndex = indHigh; // Split the 256-bit value newDataPts[indHigh].value = origDataPt.value >> 128n; // Upper 128 bits newDataPts[indLow].value = origDataPt.value & (2n ** 128n - 1n); // Lower 128 bits // Convert to hex (16 bytes each) newDataPts[indHigh].valueHex = bytesToHex( setLengthLeft(bigIntToBytes(newDataPts[indHigh].value), 16) ); newDataPts[indLow].valueHex = bytesToHex( setLengthLeft(bigIntToBytes(newDataPts[indLow].value), 16) ); return [indLow, indHigh]; // Return both wire indices } else { // Values ≤ 128 bits don't need splitting newDataPts[newIndex] = { ...origDataPt }; newDataPts[newIndex].wireIndex = newIndex; return [newIndex]; } }Example:
// Input: 256-bit value origDataPt = { value: 0x123456789ABCDEF0FEDCBA9876543210123456789ABCDEF0FEDCBA9876543210n, sourceSize: 32, // 256 bits wireIndex: 5 } // Output: Two 128-bit limbs newDataPts[10] = { value: 0xFEDCBA9876543210n, // Lower 128 bits valueHex: "0xFEDCBA9876543210", wireIndex: 10 } newDataPts[11] = { value: 0x123456789ABCDEF0n, // Upper 128 bits valueHex: "0x123456789ABCDEF0", wireIndex: 11 } // Returns: [10, 11]Result: Each original wire becomes two wires:
[wireIndex_low, wireIndex_high]- Impact: Backend circuits must now operate on pairs of 128-bit limbs instead of single 256-bit values
Update Wire Connections
- Remaps all wire references to reflect the new split structure
- Example: Placement A's output
wire[5]→[wire[10], wire[11]] - All placements that referenced
wire[5]now reference[wire[10], wire[11]]
Step 2: Permutation & Witness Generation
const permutation = new Permutation(refactoriedPlacements, _path);
permutation.placementVariables = await permutation.outputPlacementVariables(
refactoriedPlacements,
_path,
);
What happens in this step:
Build Permutation Groups (
permutation.ts:441-562)- Analyzes data flow: which placement outputs feed into which placement inputs
- Groups wires that must have identical values
- Example: If
ADD.outPts[0]→MUL.inPts[1], they belong to the same permutation group
Generate Permutation Polynomials (implements paper equation 8)
permutationX[i][h]: which wire index in placement hpermutationY[i][h]: which placement that wire references- Example:
permutationY[5][3]=1andpermutationX[5][3]=2means "wire 5 of placement 3 equals wire 2 of placement 1"
Generate Witness Data
- For each placement, computes concrete values for all wires
- Uses Circom's
witness_calculatorto run each subcircuit with its inputs - Outputs placement-specific witness files (e.g.,
placement_0_witness.json)
Step 3: Output Permutation File
permutation.outputPermutation(_path);
What this produces:
- Writes
permutation.jsoncontaining:- Complete placement list with subcircuit IDs
- Wire connection mappings (X and Y polynomials)
- Input/output buffer sizes
- Global wire indices
Why permutation is essential: In zk-SNARKs, the prover must prove that wires carrying the same logical value actually have equal field elements. The permutation polynomials encode these equality constraints, allowing the backend to verify consistency across all placements.
Output Files: For detailed information about the generated files and their structure, see Output Files.