| name | snip-36 |
| description | SNIP-36 virtual block proving on Starknet. Trigger on "virtual block", "SNIP-36", "off-chain proof", "anonymous vote", "heavy computation off-chain", "prove a transaction". Covers Cairo virtual contract, proof server, starknet.js integration, and on-chain verification. |
| license | Apache-2.0 |
| metadata | {"author":"PhilippeR26","version":"0.1.0","org":"keep-starknet-strange","source":"starknet-agentic","contributors":["PhilippeR26"]} |
| keywords | ["snip-36","virtual-block","stwo-prover","proof","privacy","anonymous-vote","cairo","starknet"] |
| allowed-tools | ["Bash","Read","Write","Glob","Grep","Task"] |
| user-invocable | true |
SNIP-36
Overview
SNIP-36 allows executing a single INVOKE_TXN_V3 off-chain against a reference Starknet block's state, then submitting a stwo-cairo proof on-chain. The proof extends the standard v3 transaction hash by appending a proof_facts_hash. Contracts verify this via get_execution_info_v3_syscall.
Core value: Run arbitrary Cairo logic off-chain (heavy computation, privacy checks, game outcomes, attribute proofs) and commit only the verified result on-chain — without revealing private inputs.
Spec: https://community.starknet.io/t/snip-36-in-protocol-proof-verification/116123
Reference implementation: https://github.com/starknet-innovation/snip-36-prover-backend
Quick Start
- Review the operator checklist.
- Add a virtual
create_proof function that emits one L2->L1 message.
- Prove an unsigned virtual tx with
snip36 prove virtual-os.
- Submit
verify_result with { proof, proofFacts } and the decoded message.
When to Use
- The user asks for SNIP-36, virtual block proving, off-chain Starknet proof generation, or proof-backed on-chain verification.
- The workflow needs heavy Cairo computation, privacy-preserving inputs, anonymous voting, secret whitelist checks, or replay-safe nullifier patterns.
- The implementation needs a Cairo virtual function, proof server, starknet.js signing flow, and on-chain
verify_result contract pattern.
When NOT to Use
- The user needs a normal Starknet transaction that should be broadcast and fee-estimated through standard RPC.
- The proof cannot run on a backend with native binaries, disk, and about 18 GB RAM.
- The security model requires SNOS-native verification instead of Phase 1 sequencer-side proof verification.
Use Cases
- Heavy computation: prove a large hash or algorithm result, then store only the verified output.
- Private attributes: prove age, whitelist membership, or voting weight with a nullifier and public boolean/result.
- Provable games: commit coin flips, seeds, bets, and outcomes for on-chain settlement.
- ZKThread or shard transitions: prove
{ old_root, new_root, ... } before updating L2 state.
Generic 3-Phase Pattern
PHASE 1 — CREATE (off-chain build)
Build a signed INVOKE_TXN_V3 that calls the virtual function.
Never broadcast. Includes public_input + private_input in calldata.
PHASE 2 — PROVE (proof server)
POST { blockNumber, tx } → snip36 prove virtual-os
Returns { proof, proofFacts, l2ToL1Messages }
Duration: ~40-50s, ~18 GB RAM
PHASE 3 — VERIFY (on-chain)
execute(verify_call, { proof, proofFacts })
Contract reads proof_facts, recomputes message hash, applies state change.
Part 1 — Cairo Contract
Scarb.toml requirements
[[target.starknet-contract]]
allowed-libfuncs-list.name = "all"
Virtual function pattern
// Called VIRTUALLY (by proof server). Never call directly on-chain.
// public_input → included in L2→L1 message (visible to verifier)
// private_input → used in computation but NEVER revealed on-chain
fn create_proof(
ref self: ContractState,
public_input: PublicInput,
private_input: PrivateInput,
) {
// 1. Compute result using both inputs
let result = heavy_computation(public_input, private_input);
// 2. Commit result as L2→L1 message — this becomes the proof output
let mut payload: Array<felt252> = array![];
// serialize fields the verifier will need:
payload.append(public_input.field1);
payload.append(result);
send_message_to_l1_syscall(
to_address: 0, // unused for SNIP-36 (no L1 delivery)
payload: payload.span()
).unwrap();
}
On-chain verify function pattern
// Called ON-CHAIN with proof attached via { proof, proofFacts }.
fn verify_result(
ref self: ContractState,
public_message: PublicMessage, // decoded from l2ToL1Messages[0].payload
) {
// 1. Read proof_facts committed by SNIP-36
let info = starknet::syscalls::get_execution_info_v3_syscall()
.unwrap_syscall().unbox();
let proof_facts = info.tx_info.unbox().proof_facts;
// 2. Recompute message hash from the submitted public_message
let message_hash = compute_message_hash(get_contract_address(), @public_message);
// 3. Assert proof integrity: proof_facts[8] must equal our hash
assert(*proof_facts[8] == message_hash, 'Proof message mismatch');
// 4. Apply state change (nullifier, store result, transfer funds, etc.)
// ...
}
proof_facts layout
index: [0, 1, 2, 3, 4, 5, 6, 7, 8, ...]
value: [0, 0, virtual_OS_prog_hash, 0, block_number, block_hash, OS_config_hash, n_messages, msg_hash_0, msg_hash_1, ...]
[7] = number of L2→L1 messages emitted (usually 1)
[8] = Poseidon hash of first message (checked against recomputed hash)
Message hash computation (Cairo)
fn compute_message_hash(contract_addr: ContractAddress, msg: @PublicMessage) -> felt252 {
let mut payload: Array<felt252> = array![];
(*msg).serialize(ref payload);
let mut data: Array<felt252> = array![
contract_addr.into(),
0_felt252,
payload.len().into(),
];
for f in payload.span() { data.append(*f); }
poseidon_hash_span(data.span())
}
Nullifier pattern (for replay protection)
const NULLIFIER_DOMAIN: felt252 = 'my_app_nullifier_v1';
fn compute_nullifier(unique_id: felt252, secret: Span<felt252>) -> felt252 {
let secret_hash = poseidon_hash_span(secret);
poseidon_hash_span(array![NULLIFIER_DOMAIN, unique_id, secret_hash].span())
}
TypeScript equivalent (must match exactly):
const NULLIFIER_DOMAIN = shortString.encodeShortString("my_app_nullifier_v1");
function computeNullifier(uniqueId: string, secret: string[]): string {
const secretHash = hash.computePoseidonHashOnElements(secret);
return hash.computePoseidonHashOnElements([NULLIFIER_DOMAIN, uniqueId, secretHash]);
}
Part 2 — Proof Server (Node.js + Rust CLI)
Setup
git clone https://github.com/starknet-innovation/snip-36-prover-backend
cd snip-36-prover-backend
cargo build --release -p snip36-cli
./target/release/snip36 setup
cp .env.example .env
Express server
import express from "express";
import { ensureBuilt } from "./build";
import { proveRouter } from "./prove";
ensureBuilt();
const app = express();
app.use(express.json());
app.use(proveRouter);
app.listen(Number(process.env.PORT ?? 3030));
POST /prove — SSE streaming endpoint
proveRouter.post("/prove", (req, res) => {
const { blockNumber, tx } = req.body ?? {};
if (!Number.isInteger(blockNumber) || blockNumber < 0 || typeof tx !== "object" || tx == null) {
res.status(400).json({ code: "SNIP36_INVALID_REQUEST", message: "blockNumber or tx is invalid" });
return;
}
const txJsonPath = `./tmp/tx-${Date.now()}.json`;
const outputBase = `./output/prove-${Date.now()}`;
const proofPath = `${outputBase}.proof`;
const proofFactsPath = `${outputBase}.proof_facts`;
const rawMessagesPath = `${outputBase}.raw_messages.json`;
const cleanupPaths = [txJsonPath, proofPath, proofFactsPath, rawMessagesPath];
const args = [
"prove", "virtual-os",
"--block-number", String(blockNumber),
"--tx-json", txJsonPath,
"--rpc-url", process.env.!,
, proofPath,
];
res.(, );
res.();
: < spawn> | ;
: . | ;
settled = ;
timedOut = ;
= () => error ? error. : (error);
= () =>
!settled && res.();
= () => {
( p cleanupPaths) {
{
(fs.(p)) fs.(p);
} (error) {
(, { : , : });
}
}
};
= () => {
(settled) ;
(timeout) (timeout);
();
settled = ;
res.();
};
{
fs.(, { : });
fs.(, { : });
fs.(txJsonPath, .(tx));
child = (, args, { : , : process. });
} (error) {
(, { : , : , : (error) });
();
;
}
timeout = ( {
timedOut = ;
(, { : , : , : });
child?.();
}, * * );
child.(, {
(, { : , : , : (error) });
();
});
child..(, (, { : , : c.() }));
child..(, (, { : , : c.() }));
child.(, {
(settled) ;
{
(timedOut) ;
(code !== ) {
(, { : , : , : });
;
}
(!fs.(proofPath) || !fs.(proofFactsPath)) {
();
}
proof = fs.(proofPath, ).();
proofFacts = .(fs.(proofFactsPath, ));
l2ToL1Messages = fs.(rawMessagesPath)
? .(fs.(rawMessagesPath, )).
: ;
(, { proof, proofFacts, ...(l2ToL1Messages && { l2ToL1Messages }) });
} (error) {
(, { : , : , : (error) });
} {
();
}
});
});
Output artifacts and env
The CLI emits *.proof (base64 stwo proof), *.proof_facts (JSON hex felt252s), and optional *.raw_messages.json with l2_to_l1_messages.
Required env: STARKNET_RPC_URL, STARKNET_ACCOUNT_ADDRESS, STARKNET_PRIVATE_KEY, STARKNET_GATEWAY_URL, STARKNET_CHAIN_ID, and PORT.
Part 3 — Starknet.js Integration
SSE client
type ProveResult = { proof: string; proofFacts: BigNumberish[]; l2ToL1Messages?: { payload: BigNumberish[] }[] };
export async function requestProof(blockNumber: number, tx: INVOKE_TXN_V3): Promise<ProveResult> {
const res = await fetch(`${process.env.PROOF_SERVER_URL ?? "http://localhost:3030"}/prove`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blockNumber, tx }),
});
const reader = res.body!.getReader();
const dec = new TextDecoder();
let buf = "";
let result: ProveResult | undefined;
while (true) {
const { done, value } = await reader.read();
(done) ;
buf += dec.(value, { : });
parts = buf.();
buf = parts.() ?? ;
( msg parts) {
event = msg.()?.[];
data = .(msg.()?.[] ?? );
(event === ) result = data;
(event === ) .( (data.), { : data., : data. });
}
}
(!result) ();
result;
}
Virtual transaction build + proof orchestration
async function proveAndVerify(publicInput: unknown, privateInput: unknown): Promise<string> {
const provider = new RpcProvider({ nodeUrl: process.env.RPC_URL! });
const account = new Account({
provider,
address: process.env.ACCOUNT_ADDRESS!,
signer: process.env.PRIVATE_KEY!,
});
const contract = new Contract({ abi, address: CONTRACT_ADDRESS, providerOrAccount: account });
const call = contract.populate("create_proof", { public_input: publicInput, private_input: privateInput });
const prices = await provider.getGasPrices();
const M = 2n;
const resourceBounds = {
l2_gas: { max_amount: * M, : prices. * M },
: { : * M, : prices. * M },
:{ : * M, : prices. * M },
};
: = account.(call, { resourceBounds });
blockNumber = provider.();
proofResult = (blockNumber, tx);
cd = (abi);
publicMessage = cd.(
,
proofResult.![]. []
);
verifyCall = contract.(, { : publicMessage });
{ transaction_hash } = account.(verifyCall, {
: proofResult.,
: proofResult.,
} );
transaction_hash;
}
SNIP-36 transaction hash extension
Standard v3: poseidon(INVOKE, version, sender, tip_rb_hash, paymaster_hash,
chain_id, nonce, da_mode, acct_deploy_hash, calldata_hash)
SNIP-36: poseidon(INVOKE, version, sender, tip_rb_hash, paymaster_hash,
chain_id, nonce, da_mode, acct_deploy_hash, calldata_hash,
proof_facts_hash) ← appended only when proof_facts present
account.execute(call, { proof, proofFacts }) handles the hash extension automatically in starknet.js.
Frontend boundaries
- Browser: collect public inputs, optionally request a wallet signature, and poll for the final transaction hash.
- Server: keep
RPC_URL, ACCOUNT_ADDRESS, PRIVATE_KEY, PROOF_SERVER_URL, getSignedTransaction(), and /prove calls off the client.
- RPC proxy: route browser RPC through a server endpoint if the provider key is sensitive.
Only expose NEXT_PUBLIC_CONTRACT_ADDRESS or other non-secret addresses to the browser.
Critical Pitfalls
| Pitfall | Fix |
|---|
| Fee estimation on virtual tx | Estimating fees online would send the full calldata (including private inputs) to the RPC node — exposing secrets. Set resourceBounds manually at 2× current gas prices instead |
| Proof generation in browser | The proving backend requires ~18 GB RAM and a native Rust binary — impossible in a browser. The proof server call must go through a backend. On-chain submission can be done from the browser (wallet) if the RPC key is not sensitive, or from the backend to hide a dedicated account private key |
Missing allowed-libfuncs-list.name = "all" | get_execution_info_v3_syscall requires it |
| Proof server resources | ~18 GB RAM, 40-50s per proof, ~10 GB disk for deps |
L2→L1 to_address for SNIP-36 | Set to 0 or any felt — no actual L1 message is sent |
| Nullifier domain mismatch between Cairo and TS | Must use identical domain string and identical hash chain |
Wrong blockNumber sent to proof server | Use provider.getBlockNumber() right before getSignedTransaction |
| Re-using a nullifier | Contract must check and revert; compute nullifier locally first to fail fast |
| Security caveat (Phase 1) | Proofs are verified by sequencer only, not by SNOS — degraded security vs native Starknet |
| Proof pricing | 130 L2gas/byte (125 propagation + 5 storage) + 10M L2gas base overhead |
Error Codes & Recovery
| Code | Name | Source / symbol | Recovery |
|---|
SNIP36_INVALID_REQUEST | invalid_request | HTTP /prove before CLI spawn | Validate inputs before calling /prove; blockNumber must be a non-negative integer and tx must be a complete INVOKE_TXN_V3 object |
SNIP36_PROVER_START_FAILED | prover_start_failed | HTTP /prove, spawn(BINARY, args) or temp file setup | Confirm the snip36 binary path, REPO_CWD, ./tmp, and ./output permissions; inspect proof server logs for the included details field |
SNIP36_PROVER_TIMEOUT | proof_generation_timeout | HTTP /prove, long-running snip36 prove virtual-os | Retry on a dedicated backend; verify the host has ~18 GB RAM, available CPU, and no stuck prior prover process |
SNIP36_PROVER_EXIT_NON_ZERO | proof_generation_failed | CLI flow: snip36 prove virtual-os | Check stderr logs, STARKNET_RPC_URL, STARKNET_CHAIN_ID, block availability, and whether the virtual transaction uses unsupported calldata or libfuncs |
SNIP36_ARTIFACT_READ_FAILED | artifact_read_failed | HTTP /prove reading .proof, .proof_facts, or .raw_messages.json | Check disk space and output permissions; verify the CLI wrote both proofPath and proofFactsPath before the server cleaned temporary files |
SNIP36_FEE_ESTIMATION_SENSITIVE | fee_estimation_sensitive | JS getSignedTransaction orchestration | Do not call fee estimation for a virtual transaction; set resourceBounds manually from before signing |
Quick Reference
Contract: create_proof(public, private) -> emit L2->L1 message
Contract: verify_result(public_msg) -> check proof_facts[8] and apply state
Optional: read_result() -> query stored result/nullifier
Facts: proof_facts[7] = n_messages; proof_facts[8] = poseidon(contract, 0, len, payload)
CLI: snip36 prove virtual-os --block-number N --tx-json tx.json --rpc-url URL --output out.proof
HTTP: POST /prove { blockNumber, tx } -> SSE log* then done|error
JS: getSignedTransaction(call, { resourceBounds }) then execute(verifyCall, { proof, proofFacts })