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 . Contracts verify this via .
proof_facts_hash
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.
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"# required for get_execution_info_v3_syscall
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.)
// ...
}
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