Help us improve
Share bugs, ideas, or general feedback.
Runs Starknet transactions off-chain and submits zk-proofs for on-chain verification. Use for heavy Cairo computation, private inputs, anonymous voting, or replay-safe nullifier patterns.
npx claudepluginhub keep-starknet-strange/starknet-agentic --plugin starknet-agentic-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/starknet-agentic-skills:snip-36This skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
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`.
Guides building zero-knowledge proof verifiers and privacy patterns on Stellar/Soroban, covering Groth16, BLS12-381, BN254, Poseidon, Noir/RISC Zero integration, and more.
Build Starknet dApps using starknet.js v9.x SDK reference for providers, accounts, contracts, transactions, fee estimation, wallets, and paymasters.
This skill should be used when the user asks about zero-knowledge proofs, ZK SNARKs, witness data, prover/verifier roles, constraint systems, proof generation, proof verification, privacy boundaries, or how Midnight uses ZK cryptography for transaction privacy and data protection.
Share bugs, ideas, or general feedback.
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
create_proof function that emits one L2->L1 message.snip36 prove virtual-os.verify_result with { proof, proofFacts } and the decoded message.verify_result contract pattern.{ old_root, new_root, ... } before updating L2 state.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.
[[target.starknet-contract]]
allowed-libfuncs-list.name = "all" # required for get_execution_info_v3_syscall
// 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();
}
// 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.)
// ...
}
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)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())
}
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]);
}
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
# STARKNET_RPC_URL=... (v0.8+ JSON-RPC, e.g. Alchemy)
# STARKNET_ACCOUNT_ADDRESS=...
# STARKNET_PRIVATE_KEY=...
# STARKNET_GATEWAY_URL=https://alpha-sepolia.starknet.io
// src/index.ts
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));
// src/prove.ts
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.STARKNET_RPC_URL!,
"--output", proofPath,
];
res.setHeader("Content-Type", "text/event-stream");
res.flushHeaders();
let child: ReturnType<typeof spawn> | undefined;
let timeout: NodeJS.Timeout | undefined;
let settled = false;
let timedOut = false;
const errorDetails = (error: unknown) => error instanceof Error ? error.message : String(error);
const send = (event: "log" | "done" | "error", data: object) =>
!settled && res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const cleanup = () => {
for (const p of cleanupPaths) {
try {
if (fs.existsSync(p)) fs.unlinkSync(p);
} catch (error) {
send("log", { stream: "cleanup", line: `failed to remove ${p}: ${errorDetails(error)}` });
}
}
};
const finish = () => {
if (settled) return;
if (timeout) clearTimeout(timeout);
cleanup();
settled = true;
res.end();
};
try {
fs.mkdirSync("./tmp", { recursive: true });
fs.mkdirSync("./output", { recursive: true });
fs.writeFileSync(txJsonPath, JSON.stringify(tx));
child = spawn(BINARY, args, { cwd: REPO_CWD, env: process.env });
} catch (error) {
send("error", { code: "SNIP36_PROVER_START_FAILED", message: "failed to start snip36", details: errorDetails(error) });
finish();
return;
}
timeout = setTimeout(() => {
timedOut = true;
send("error", { code: "SNIP36_PROVER_TIMEOUT", message: "snip36 timed out", details: "terminated after 10 minutes" });
child?.kill("SIGTERM");
}, 10 * 60 * 1000);
child.on("error", error => {
send("error", { code: "SNIP36_PROVER_START_FAILED", message: "snip36 process failed", details: errorDetails(error) });
finish();
});
child.stdout.on("data", c => send("log", { stream: "stdout", line: c.toString() }));
child.stderr.on("data", c => send("log", { stream: "stderr", line: c.toString() }));
child.on("close", code => {
if (settled) return;
try {
if (timedOut) return;
if (code !== 0) {
send("error", { code: "SNIP36_PROVER_EXIT_NON_ZERO", message: "snip36 exited non-zero", details: `exit code ${code}` });
return;
}
if (!fs.existsSync(proofPath) || !fs.existsSync(proofFactsPath)) {
throw new Error("missing proof or proof_facts artifact");
}
const proof = fs.readFileSync(proofPath, "ascii").trim();
const proofFacts = JSON.parse(fs.readFileSync(proofFactsPath, "ascii"));
const l2ToL1Messages = fs.existsSync(rawMessagesPath)
? JSON.parse(fs.readFileSync(rawMessagesPath, "ascii")).l2_to_l1_messages
: undefined;
send("done", { proof, proofFacts, ...(l2ToL1Messages && { l2ToL1Messages }) });
} catch (error) {
send("error", { code: "SNIP36_ARTIFACT_READ_FAILED", message: "failed to read proof artifacts", details: errorDetails(error) });
} finally {
finish();
}
});
});
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.
// requestProof.ts
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();
if (done) break;
buf += dec.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const msg of parts) {
const event = msg.match(/^event: (\w+)/)?.[1];
const data = JSON.parse(msg.match(/^data: (.+)$/m)?.[1] ?? "null");
if (event === "done") result = data;
if (event === "error") throw Object.assign(new Error(data.message), { code: data.code, details: data.details });
}
}
if (!result) throw new Error("No proof result");
return result;
}
// server-side only: private inputs and signing keys must never reach the browser.
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 });
// 1. Build virtual call (never broadcast)
const call = contract.populate("create_proof", { public_input: publicInput, private_input: privateInput });
// 2. Must set resourceBounds manually; fee estimation would expose private calldata.
const prices = await provider.getGasPrices();
const M = 2n;
const resourceBounds = {
l2_gas: { max_amount: 0x279fc0n * M, max_price_per_unit: prices.l2GasPrice * M },
l1_gas: { max_amount: 0xbd2an * M, max_price_per_unit: prices.l1GasPrice * M },
l1_data_gas:{ max_amount: 0xc0n * M, max_price_per_unit: prices.l1DataGasPrice * M },
};
// 3. Requires the proof-enabled starknet.js fork, not standard starknet.js.
const tx: INVOKE_TXN_V3 = await account.getSignedTransaction(call, { resourceBounds });
const blockNumber = await provider.getBlockNumber();
const proofResult = await requestProof(blockNumber, tx);
const cd = new CallData(abi);
const publicMessage = cd.decodeParameters(
"my_contract::PublicMessage",
proofResult.l2ToL1Messages![0].payload as string[]
);
const verifyCall = contract.populate("verify_result", { public_message: publicMessage });
const { transaction_hash } = await account.execute(verifyCall, {
proof: proofResult.proof,
proofFacts: proofResult.proofFacts,
} as any);
return transaction_hash;
}
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.
RPC_URL, ACCOUNT_ADDRESS, PRIVATE_KEY, PROOF_SERVER_URL, getSignedTransaction(), and /prove calls off the client.Only expose NEXT_PUBLIC_CONTRACT_ADDRESS or other non-secret addresses to the browser.
| 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 |
| 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 provider.getGasPrices() before signing |
SNIP36_STALE_BLOCK_NUMBER | stale_block_number | JS provider.getBlockNumber() and HTTP /prove | Fetch provider.getBlockNumber() immediately before getSignedTransaction() and retry with a recent finalized block |
SNIP36_PROOF_FACTS_MISSING | proof_facts_missing | Cairo verify_result, get_execution_info_v3_syscall() | Ensure the on-chain account.execute(verifyCall, { proof, proofFacts }) includes both fields and uses the starknet.js proof hash extension |
SNIP36_MESSAGE_HASH_MISMATCH | proof_message_mismatch | Cairo verify_result, proof_facts[8] check | Compare the create_proof L2->L1 payload with the public_message passed to verify_result; check serialization order and compute_message_hash inputs |
SNIP36_NULLIFIER_MISMATCH | nullifier_mismatch | Cairo compute_nullifier, TS computeNullifier | Verify the domain string, short-string encoding, field order, and Poseidon hash chain match exactly across Cairo and TypeScript |
SNIP36_REUSED_NULLIFIER | reused_nullifier | Cairo verify_result state update, optional read_result() | Abort submission, call read_result() or the nullifier storage view, and surface a replay/conflict error to the user |
SNIP36_RPC_CHAIN_MISMATCH | rpc_chain_mismatch | CLI/HTTP env: STARKNET_RPC_URL, STARKNET_GATEWAY_URL, STARKNET_CHAIN_ID | Align RPC, gateway, and account network; rebuild the virtual transaction after correcting environment variables |
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 })