Все записи
2026-05-26

How zero-knowledge proofs actually work inside SolMask

What does a zero-knowledge proof actually prove?

A zero-knowledge proof is a way to convince someone that you know a particular piece of information without showing them the information itself. That is the entire idea. There is no magic — just arithmetic that, when arranged carefully, produces a short string of bytes that a verifier can check in fixed time, and that string can only have been produced by someone who actually possessed the secret. If the prover did not know the secret, they could not have constructed the bytes. If they did, the bytes are produced and look like every other valid proof. The verifier learns one bit: "you know it, or you don't." Nothing else leaks.

Inside SolMask, the secret is the contents of a shielded note. A note is the on-chain object that represents your deposit inside the pool. It does not appear in plaintext anywhere on Solana — only its commitment does, which is a Poseidon hash of the note's fields. The fields include a secret, a nullifier_secret, the deposited amount, the chosen unlock_slot, and a mint identifier. The commitment is a 32-byte leaf in a Merkle tree that lives in a program-owned account. Every deposit appends one leaf; the tree grows; the root changes; the new root is what later proofs reference.

When you withdraw, you do not point to your leaf. Pointing to it would be the whole problem — it would link the deposit to the withdraw and undo the privacy. Instead, you build a proof that asserts, in one mathematical statement, four things at once. First, that you know the preimage of some commitment in some recent Merkle tree state — that is, you can reconstruct the path from a leaf up to a root the program has recorded. Second, that the current Solana slot is greater than or equal to the unlock_slot baked into that note. Third, that the amounts you are routing out — to a recipient, to a change-note, and to the protocol fee — sum correctly to what was originally deposited in that note. Fourth, that the nullifier_hash you are publishing was derived from the same nullifier_secret sitting inside the note, and that the chain has not seen this nullifier before. Those four facts, in one proof.

The Merkle path is the part most people find counterintuitive. You give the circuit your leaf, the sibling hashes at each level, and a claim about which root the path resolves to. The circuit recomputes the Poseidon hash up the tree using your siblings, ends up with a 32-byte value, and asserts that value equals the public root that the on-chain program will look up. The circuit does not know which leaf — it just knows the leaf hashes upward to the root. From outside, every withdraw of a given mint and amount looks identical. There is no observable difference between someone withdrawing the note from block 100 and someone withdrawing the note from block 250,000, as long as both roots are valid.

What does the verifier on Solana actually see?

The verifier is a Solana program — the SolMask withdraw instruction. It runs inside a normal transaction. It does not run the entire Groth16 prover; that would be impossibly expensive on-chain. It runs the verification algorithm, which for Groth16 is a small fixed-cost computation involving a handful of elliptic-curve pairings. On Solana, those pairings are exposed as syscalls (alt_bn128) and consume a known, bounded amount of compute units regardless of how complicated the underlying statement was. That is the whole point of Groth16: the proof and the verifier cost are tiny, while the statement being proved can be as gnarly as the circuit designer wants.

What the verifier reads from the transaction is a short list of public inputs and the proof itself. The public inputs are: the Merkle root the proof claims membership in, the nullifier_hash, the recipient address, the fee recipient (the fee destination), the current slot bound, the mint, the amount_to_recipient, and the change_amount that goes back into the pool as a fresh note. The proof is 256 bytes — three elliptic-curve points encoded compactly. The verifier checks the pairing equation. If it holds, it then performs a few additional non-ZK checks: the nullifier has not been used before (it inserts the nullifier hash into the on-chain nullifier set, which is a hash-map-backed PDA), the root is one the program has recorded within its rolling history window, and the slot bound is consistent with the actual current slot. If anything fails, the instruction aborts. Nothing else is logged. Nothing else is revealed.

Notice what is not in that public-input list. The secret. The nullifier secret. The original deposit's leaf index. The original deposit's slot. The depositor's wallet. The amount deposited (versus the amount being withdrawn, which is public). The change note's commitment is public, but it is a fresh Poseidon hash with new randomness, so it links to nothing observable. The link between the deposit transaction and the withdraw transaction is the thing that has been cut. That cut is the privacy.

Why Groth16 and not something newer?

Groth16 is the oldest of the modern proving systems, dating from 2016, and it has been the workhorse for production privacy systems ever since. SolMask uses it deliberately. Two reasons. First, proof size: 256 bytes. That fits comfortably inside a Solana transaction's data budget, leaving room for the public inputs, the recipient transfer, the change-note insertion, the nullifier insertion, and the optional Jupiter swap that converts the withdrawn asset into the recipient's chosen asset. A larger proof would crowd the transaction; a 2 KB proof from a more modern system would force splitting the withdraw across multiple transactions, and split flows are an information leak in themselves. Second, verifier cost: the on-chain pairing check is roughly 100k-200k compute units on Solana with the alt_bn128 syscalls. Plonk is comparable but with a different cost curve; STARKs are larger and slower to verify on-chain; Halo2 has improving tooling but the verifier is heavier. The trade-off Groth16 demands is the trusted setup. We accepted that trade because it puts the cost on us once and the savings on every user forever.

A Groth16 setup is circuit-specific. Each time the withdraw circuit changes, the setup has to be redone. That is annoying in practice but acceptable in a system where the circuit changes slowly — the SolMask withdraw circuit is intended to be stable for years, with multi-recipient and shielded-transfer extensions added as separate circuits with their own setups rather than as edits to the core one. See /docs/circuits for the full circuit catalog and which setup covers which.

What does the trusted setup actually mean?

The trusted setup is the one piece of SolMask that is not pure math you can re-derive from first principles. To build a Groth16 prover and verifier for a given circuit, you need a "proving key" and a "verifying key" — large structured blobs of elliptic-curve points generated from a random secret called the toxic waste. If a single party generates the keys alone and keeps the toxic waste, that party can forge proofs forever. If the toxic waste is destroyed, no one can forge anything; the keys become trustworthy artifacts. The trick is convincing everyone that the toxic waste was actually destroyed.

The standard answer is a multi-party computation. Many participants each contribute randomness, in sequence, to the setup. Each participant performs a transformation on the running state of the keys, mixes in their own secret, and then destroys their secret. The cryptographic property is: the final keys are safe as long as at least one participant was honest. You only need one. If five hundred people contribute and four hundred and ninety-nine of them are compromised, the setup is still secure, because the one honest participant's randomness scrambled the toxic waste irrecoverably.

SolMask's withdraw circuit went through a phase-2 ceremony with a public participant list, transcript hashes published per round, and a Bitcoin block beacon at the end. The beacon is a final non-malleable randomness source: we declared in advance that the hash of a future Bitcoin block at a specified height would be mixed in as the last contribution. That block has now been mined; its hash is in our transcript; no one could have manipulated the result without controlling Bitcoin mining, which is to say no one could have manipulated it. The full transcript, every participant's attestation, and the final keys are at /docs/trusted-setup. That is the one thing you trust. After that, every withdraw proof is verifiable math.

What is the proof actually doing, with a concrete example?

Say you deposit 1 SOL. Deposits are free, so the deposit instruction credits the full amount and writes a note with amount = 1 SOL (the protocol fee is charged later, at withdraw time). You chose a 10-minute delay; the note's unlock_slot is set to the current slot plus 1,500 slots (10 minutes at ~400 ms/slot). The note's commitment — Poseidon of (secret, nullifier_secret, 1 SOL, unlock_slot, mint) — is appended to the Merkle tree. The note's secrets are derived from your wallet (no file to save), and the deposit publishes a wallet-encrypted recovery blob on-chain so any device with the same wallet can rediscover it; the indexer publishes the new root.

Ten minutes pass. Meanwhile, three other people deposit. The tree now has four new leaves added since yours. Roots have been advancing. Now you want to withdraw to a fresh wallet that has never touched the pool.

In the browser, snarkjs and the circuit WASM run for somewhere between 5 and 30 seconds depending on hardware. They take your note, the current Merkle path from your leaf to a recent root, the recipient address, your chosen amount_to_recipient (let's say all of it: 0.9947 SOL after the 0.003 SOL withdraw fee, with change_amount = 0), and produce 256 bytes of proof plus the derived nullifier_hash. You send those to the relayer.

The relayer builds a transaction: invoke the withdraw instruction with the proof, public inputs, and accounts. It submits the transaction as the fee payer, so it covers the SOL the network charges to land it — that network gas is a cost the relayer absorbs. It also fronts the on-chain 0.003 SOL withdraw fee to the fee destination and recoups exactly that fee via a tip slice in the deposit asset; the fee itself is protocol revenue. The transaction lands. The chain sees: a 256-byte proof, a never-before-seen nullifier hash entering the nullifier set, 0.9947 SOL flowing out of the program vault to your fresh wallet, 0.003 SOL flowing to the fee destination. The chain does not see which of the recent deposits your withdraw corresponds to. Anyone watching has four candidates plus every prior deposit still in the active root window. That is the anonymity set. That is what the proof bought you.

What does ZK not do?

A zero-knowledge proof cuts one specific edge in the on-chain transaction graph. It does not make you invisible in any larger sense. It does not hide your IP from the relayer or the RPC you sent the transaction to — the relayer sees your origin IP unless you used Tor, a VPN, or your own infrastructure. It does not hide the fact that a withdraw happened — every withdraw is publicly visible, just unlinked from a specific deposit. It does not hide off-chain correlations: if you tell three people you withdrew exactly 0.9947 SOL at 3:14pm, and only one withdraw of that amount happened at 3:14pm, you have leaked the link yourself.

It also does not protect you against analysis at the edges — the moment you spent the withdrawn SOL on something identifying, the privacy of what you do next depends on your behavior, not on the proof. Choose a fresh recipient that has no history. Do not immediately bridge to a known KYC address. Do not deposit and withdraw round-number amounts that match exactly. The proof gives you a clean cut; what you do with the clean side is up to you. See /learn/verifying-your-deposit for the user-facing checklist, and /glossary/nullifier, /glossary/commitment, and /glossary/groth16-proof for the term definitions.

FAQ

How long does proving take in the browser? Between 5 and 30 seconds on a recent laptop, dominated by the WASM-based Groth16 prover from snarkjs. Older machines and mobile take longer; we cap the UI at 60 seconds and surface a progress estimate. The proving happens entirely client-side — no witness leaves your device.

How big is the proof on the wire? 256 bytes for the proof itself, plus the public inputs (root, nullifier hash, recipient, fee recipient, slot, mint, amounts) which add up to a few hundred bytes more. The whole withdraw transaction comfortably fits inside a single Solana transaction.

Can the verifier program be tricked into accepting a bad proof? Not if the pairing equation holds, which it can't unless the prover knew the secret or the elliptic-curve discrete log problem is broken. The verifier code itself is short and audited; it is essentially a wrapper around the alt_bn128 pairing syscall.

What happens if someone tries to withdraw twice with the same note? The first withdraw inserts nullifier_hash into the on-chain nullifier set. The second withdraw, even with a freshly generated proof, derives the same nullifier_hash from the same nullifier_secret — the insertion fails, the instruction aborts. The note is single-use by construction.

Does the trusted setup need to be redone if SolMask adds features? Only for circuits that change. The withdraw circuit's setup is independent of any other circuit; adding a multi-recipient or shielded-transfer circuit means a new, separate ceremony for that circuit. Existing notes and existing withdraw proofs are unaffected.

Is Groth16 quantum-safe? No — it relies on elliptic-curve cryptography, which a sufficiently large quantum computer would break. No production privacy system on Solana is quantum-safe today. Post-quantum proving systems exist (some STARK variants) but are not yet practical for on-chain verification on Solana. We track this and will migrate when the trade-offs make sense.

Is the proof itself ever stored on-chain after verification? No. The proof is consumed by the verifier inside the withdraw instruction and discarded. What persists is the inserted nullifier hash, the change-note commitment, the recipient transfer, and the fee transfer. The proof bytes are visible in the transaction's instruction data on chain history, but the program does not retain them after the slot is processed.

How zero-knowledge proofs actually work inside SolMask · SolMask