Skip to content

fix: add STARK proof verification to unshield (security fix) - #4

Open
zk-sable wants to merge 1 commit into
IsSlashy:gsd/phase-1-foundationfrom
zk-sable:feat/stark-verifier-cpi
Open

fix: add STARK proof verification to unshield (security fix)#4
zk-sable wants to merge 1 commit into
IsSlashy:gsd/phase-1-foundationfrom
zk-sable:feat/stark-verifier-cpi

Conversation

@zk-sable

@zk-sable zk-sable commented Feb 5, 2026

Copy link
Copy Markdown

🔒 Security Fix: Add STARK Proof Verification to unshield

The Problem

The current unshield instruction accepts merkle_root and nullifier_hash as parameters but does not verify any ZK proof. The existing comment even acknowledges this:

// Update merkle root (in real ZK system, this would be verified via proof)
pool.merkle_root = new_root;

This means anyone can drain the shielded pool by calling unshield with arbitrary values. The nullifier PDA prevents double-spend of the same nullifier, but an attacker can generate fresh nullifiers at will.

The Fix

This PR adds STARK proof verification by integrating with the Murkl STARK Verifier (StArKSLbAn43UCcujFMc5gKc8rY2BVfSbguMfyLTMtw), which is deployed on devnet.

The unshield instruction now requires a verifier_buffer account that:

  1. Is owned by the STARK Verifier program
  2. Has finalized == true (proof was cryptographically verified on-chain)
  3. Contains a nullifier that matches the withdrawal's nullifier_hash
  4. Contains a merkle_root that matches the withdrawal's new_root

How It Works

User (off-chain)                         On-chain
────────────────                         ────────
1. Generate STARK proof (WASM, ~2s)
2. init_proof_buffer()          →  Create buffer account
3. upload_chunk() × N           →  Upload ~8.7KB proof
4. finalize_and_verify()        →  STARK verification (~31K CU)
5. Call unshield(buffer)        →  Read buffer, check finalized + inputs

The verifier buffer layout (from Murkl SDK):

[0..32]    owner           (Pubkey)
[32..36]   size            (u32)
[36..40]   expected_size   (u32)
[40]       finalized       (bool) ← we check this
[41..73]   commitment      ([u8; 32])
[73..105]  nullifier       ([u8; 32]) ← must match nullifier_hash
[105..137] merkle_root     ([u8; 32]) ← must match new_root
[137..]    proof_data

Changes

  • instructions/unshield.rs: Added verifier_buffer account + proof verification logic
  • errors.rs: Added 4 new error variants for proof verification failures

Why STARKs over SNARKs?

Your circuit files use circom (Groth16 SNARKs). STARKs offer advantages for a shielded pool:

Groth16 SNARKs STARKs (Murkl)
Trusted setup ❌ Required per-circuit ✅ None
Post-quantum ❌ Vulnerable ✅ Secure
Proof size ~200 bytes ~8.7 KB
On-chain CU ~200K CU ~31K CU
Transparency ❌ Hidden params ✅ Fully transparent

The 8.7KB proof size is larger but is uploaded in chunks before verification — the actual unshield call just reads the buffer (minimal CU).

Integration with Existing Circuits

Your circom circuits define the correct transfer logic (2-in-2-out, value conservation, range checks). The STARK prover would implement the same constraints in the M31 field. The circuit logic is compatible — only the proof system changes.

Testing

To test on devnet:

  1. Use the Murkl SDK to create and upload a proof
  2. Pass the buffer address as verifier_buffer in the unshield call
  3. The program verifies the buffer's finalized flag and public inputs

Live demo: https://murkl-relayer-production.up.railway.app

Breaking Change

This is a breaking change — existing unshield callers must now provide a verifier_buffer account. This is intentional since the previous version was insecure.


Related: #2 (STARK Integration issue)

SECURITY FIX: The unshield instruction previously accepted merkle_root
and nullifier_hash without any ZK proof verification, allowing anyone
to drain the shielded pool by submitting arbitrary values.

This commit adds STARK proof verification by reading from Murkl's
on-chain verifier buffer (StArKSLbAn43UCcujFMc5gKc8rY2BVfSbguMfyLTMtw).
The verifier buffer contains a STARK proof that has been cryptographically
verified on-chain, with the following public inputs:
  - commitment: the note commitment
  - nullifier: prevents double-spend
  - merkle_root: proves membership in the current tree

Flow:
  1. User generates STARK proof off-chain (Murkl WASM prover, ~2s)
  2. Upload proof to verifier buffer (init + upload_chunk + finalize)
  3. Call unshield with verifier_buffer account
  4. Program verifies: finalized=true, nullifier matches, root matches

Benefits over previous SNARK approach (circom/Groth16):
  - No trusted setup ceremony required
  - Post-quantum secure (128-bit security level)
  - ~31K CU per verification on Solana
  - Transparent — all parameters are public

New error variants: InvalidProofBuffer, ProofNotVerified,
ProofNullifierMismatch, ProofMerkleRootMismatch

See: https://github.com/exidz/murkl/blob/main/docs/INTEGRATION.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant