mirror-pool: two-path behavioral anonymity pool (synchronized crowd + ZK-deniable initiation) - #1
Open
marcelofeitoza wants to merge 67 commits into
Open
Conversation
Tornado-Cash-for-behavior: a shared anonymity set over the INITIATORS of an action, not over funds. Participants commit an intent; a whole epoch settles on one shared timestamp via a gasless rotating relay, so an observer sees the action but not who initiated it. Workspace (all host crates test green, clippy clean): - mirror-core: commitment/nullifier/epoch/k-anon primitives + on-chain wire format - mirror-coordinator: shared-epoch batching, hard k-floor gate, rotating gasless relay, normalized tx shape (scheduler + tests real; RPC submit stubbed) - mirror-cli: participant commit/status (prove is v2) - mirror-harness: adversarial evaluation. FIFO temporal-matching advantage collapses from +77.5pp (per-actor delay) to ~0 (shared-epoch) at k=16; amount / fingerprint / gas-payer attacks also collapse under batching + fixed buckets - mirror-behaviors: Behavior trait + pooled Jupiter-swap / jitoSOL-stake stubs - programs/mirror-pool: Pinocchio on-chain program, builds with cargo build-sbf Docs: README, THREAT_MODEL, ARCHITECTURE, ROADMAP. MIT. Clean-room: no external dependencies on any other codebase. Dev/test runs against Surfpool (local mainnet mirror); v1 logic (frontier Merkle accumulator, nullifier PDAs, atomic settlement, real tx submission) is the next milestone.
…fiers) Pinocchio program with fail-closed InitPool/Commit/SettleEpoch: - Pool PDA (seeds ["pool", authority]): config + depth-20 frontier Merkle accumulator (keccak256 nodes), commitment_count, current_root, settle authority, entry_fee (anti-Sybil), bump. - Epoch PDA (seeds ["epoch", pool, epoch_id]): per-epoch commit_count + settled flag, so k-floor and double-settle prevention are enforced on-chain. - Nullifier PDA (seeds ["nf", pool, epoch_id, nullifier]): existence = spent. - Commit derives the current epoch from the Clock, appends to the accumulator, increments the epoch commit_count, and collects entry_fee. - SettleEpoch checks (in order): authority signer == pool.authority; window closed; not already settled; commit_count >= k_floor; create each nullifier PDA (fail on replay); mark settled. Behavior CPI is the documented next hook. 7 mollusk integration tests pass; cargo build-sbf green; clippy/fmt clean. mirror-core wire gains INIT_POOL_LEN (21), byte-identical to the program's mirror. Accumulator hashing is keccak256 for this milestone; it migrates to Poseidon next to match the ZK membership circuit (ZK-deniable initiation).
On-chain program is Implemented (accumulator + Epoch/Nullifier PDAs + k-floor, 7 mollusk tests, build-sbf green), not pending; harness is Implemented. Reframe away from a v1/v2 split: ZK-deniable initiation (Groth16 membership) is core scope, in progress. Add circuits/ and the Surfpool soak suite to the status table; mark coordinator/cli/behaviors in progress.
…ures circom membership proof for ZK-deniable initiation. Canonical scheme: - commitment = Poseidon(secret, actionHash, epoch); nullifierHash = Poseidon(secret, epoch) - depth-20 Poseidon Merkle; public inputs [root, nullifierHash, actionHash, epoch] - 11522 R1CS constraints; groth16 setup over a public 2^16 powers-of-tau. Artifacts for the on-chain verifier (groth16-solana 0.2.0 byte layout, validated end-to-end against the real crate): artifacts/vk.rs, artifacts/proof_fixture.rs (PROOF_A pre-negated), plus verification_key.json and a snarkjs-verified fixture. build.sh + gen_fixture.js reproduce everything; large binaries (ptau/zkey/wasm) are gitignored. Dev/test trusted setup only; a real ceremony is separate. circuits/node_modules and build binaries added to .gitignore.
…ib Poseidon mirror-core, the on-chain accumulator, and the ZK circuit now share one identical circomlib-Poseidon scheme over BN254 (big-endian). Host uses light-poseidon; on-chain uses the sol_poseidon syscall (no new SBF dep). Cross-check tests prove agreement: mirror-core reproduces the circuit fixture's nullifierHash and Merkle root, and the on-chain empty root matches the host Poseidon zero ladder. Additive API (action_hash, commit_with_action_hash, merkle_node); no existing signatures changed, so coordinator/cli/harness are untouched. mirror-core 7 tests, program 8 mollusk tests, build-sbf green.
…piter, jitoSOL) Behavior trait + PlainTransfer (fixed-shape SOL/SPL, the guaranteed soak baseline), JupiterSwap (quote + swap-instructions, live path env-gated), and JitoSolStake (SPL stake-pool DepositSol), plus a registry and SizeBucket->amount mapping. Instruction layouts are hand-built to keep one solana type set repo-wide. 19 tests. Used by the crowd path: the coordinator composes SettleEpoch + N participant-signed identical actions into one atomic v0 tx (N+1 signer, ALT for shared accounts).
SolanaClient RPC boundary (real + mock) and a crowd-path tx builder: one atomic v0 transaction = normalized ComputeBudget + SettleEpoch + N participant-signed behavior instructions, N+1 signers (rotating fee-payer + each participant signs their own action), shared accounts in an ALT, participant signers and per-epoch PDAs kept in static keys. Fail-closed 1232-byte size check; plan_settlements chunks large epochs. RpcSettleSubmitter drops into the existing scheduler seam via register_epoch/register_signer/submit_crowd. Deps isolated to the crate. 11 tests pass (6 scheduler + 5 crowd); clippy/fmt clean. Known constraint: on-chain SettleEpoch is single-shot per epoch, so >4-participant PlainTransfer epochs need the ALT to absorb nullifier PDAs or a settle-in-parts capability; documented for follow-up.
… + escrow) Adds the cryptographic opt-in path alongside the crowd path: - CommitDeposit (tag 3): escrows the action input into the pool and appends the commitment to the shared Poseidon accumulator. - SettleZk (tag 4): verifies a Groth16 membership proof on-chain via groth16-solana (public inputs [root, nullifierHash, actionHash, epoch]), checks the root against a new 32-entry root-history ring, enforces actionHash binding + nullifier anti-replay + relay authority + closed window, then executes the action (escrow -> fresh output). No participant wallet is named at settle, so which committer initiated cannot be determined. actionHash = Poseidon(recipient_hi128, recipient_lo128, amount) computed identically on host (mirror-core), on-chain (sol_poseidon), and in the circuit, so a relay cannot redirect the output. The committed proof fixture (regenerated against the same zkey, so vk is byte-identical) verifies on-chain and a mutated public input is rejected. Pool layout grows a root-history ring (LEN 734 -> 1762). 17 program mollusk tests (crowd + ZK), 9 mirror-core tests, build-sbf green, clippy/fmt clean. New errors: ProofVerificationFailed, RootNotKnown, ActionHashMismatch, InsufficientEscrow.
…al-k InitPool gains reward_bps (INIT_POOL_LEN 21->23): a share of each entry fee accrues to an on-chain reward pool. ClaimReward (tag 5) pays crowd-path participants proportional to dwell (epochs participated), tracked in a Participant PDA, drain-safe (never pays more than the pool holds; double/over-claim rejected). The ZK path funds the reward pool but has no identity-linked claim; the anonymity-preserving equivalent (a second Groth16 dwell proof) is documented in docs/INCENTIVES.md, not implemented, so claiming can never deanonymize. Coordinator surfaces real_k (nominal minus operator decoys minus Sybil-flagged) in every settlement outcome and log; users are shown real_k, never nominal. Pool layout grew additively 1762 -> 1780 (no existing offset shifted). 24 program mollusk tests, 13 coordinator, 9 core; build-sbf green; clippy/fmt clean.
… status) Real RPC commands. Crowd path: commit posts a commitment binding secret+action+ epoch. ZK path: deposit-commit escrows lamports and binds the fresh recipient + amount into actionHash; prove rebuilds the Merkle path from a frontier snapshot captured at commit (Surfpool-fork friendly, no historical-tx dependency), generates and verifies a Groth16 proof via snarkjs, and emits the SettleZk instruction for the gasless relay to submit (it never submits itself). Tolerant Pool decoder (>= expected length) so additive on-chain growth does not break readback; init-pool carries reward_bps. 26 tests, including a gated live prove pipeline whose emitted proof bytes are identical to the fixture the on-chain verifier accepts. Notes saved under notes/ (gitignored).
…h system The prior docs were stale (claimed SHA-256, 3 instructions, ZK as v2/planned). Now accurate to source: Poseidon end-to-end; six instructions (InitPool, Commit, CommitDeposit, SettleEpoch, SettleZk, ClaimReward) with exact layouts + PDA seeds; both settlement flows (crowd N+1-signer atomic composition with ALT; ZK deposit-escrow + on-chain Groth16 membership + fresh-output); root-history ring; Poseidon + alt_bn128 verification; Solana tx-size/ALT/Jito constraints; a flow diagram and an all-Implemented status table; public-only prior art. THREAT_MODEL now states each path's distinct guarantee honestly: crowd defeats copy-trading/signal extraction (action stays on the named wallet), ZK gives cryptographic who-initiated unlinkability. ROADMAP reframed built-vs-future.
Add crates/mirror-soak, an automated end-to-end soak driver that runs against a live local Surfpool validator (treated as a mainnet mirror) and verifies every effect on-chain: - Setup: airdrop a relay/authority + payer, ensure the program is deployed, InitPool a fresh pool (nonzero entry fee + reward split), create the pool ALT. - Crowd path: 4 participants commit the same PlainTransfer action into one shared epoch; after the window closes the gasless coordinator settles ONE atomic tx (ComputeBudget + SettleEpoch + 4 identical transfers). Verifies the epoch is settled, 4 nullifier PDAs exist, and the 4 transfers landed. - ZK opt-in path: mirror-cli deposit-commit escrows to a fresh recipient; mirror-cli prove produces a snarkjs-verified Groth16 SettleZk the relay submits. Verifies the escrow landed at the fresh recipient, the nullifier PDA exists, and a replay is rejected. - Adversarial: under-floor epoch does not settle (on-chain BelowKFloor + off-chain coordinator roll-forward), duplicate crowd nullifier rejected (NullifierSpent), re-settle rejected (EpochAlreadySettled), ZK mismatched recipient rejected (ActionHashMismatch). Writes docs/PROOF.md with the captured tx signatures and verified on-chain effects. Runnable as `cargo run -p mirror-soak --`, configurable via args, defaulting to the running Surfpool + the built program id. Also fix a latent bug in the coordinator's ALT setup path that the live run surfaced: create_lookup_table does not require the authority to sign, so the fixed [authority, payer] signer set over-counted and failed with "too many signers" on a real RPC (masked in unit tests by the mock). send_setup_ix now signs with exactly the keys the compiled message requires.
All components Implemented; status table + workspace layout updated to include mirror-soak and the ZK path; links to PROOF.md (live Surfpool soak, 17/17 on-chain assertions) and INCENTIVES.md. Reflects the final verified state: host tests + 24 program mollusk tests + build-sbf + clippy -D warnings all green.
thomgabriel
added a commit
to thomgabriel/mirror-pool
that referenced
this pull request
Jul 17, 2026
stake_account_pda's doc comment pointed at round.rs::execute_round; execute_round lives in lib.rs (round.rs holds only the data types). Code-craft punch-list solanabr#1.
…etup + vk/fixtures Additive confidential-value layer (clean-room from the public Tornado-Nova design; membership.circom untouched). transaction.circom proves: 2 input notes (dummy-allowed) are in the accumulator, correct nullifiers, 2 output commitments, value conservation (sum in + publicAmount = sum out), output range proofs, and an extDataHash binding. Note scheme: commitment = Poseidon(amount, pubkey, blinding), nullifier = Poseidon(commitment, pathIndices, Poseidon(priv, commitment, pathIndices)). publicAmount uses the FIELD_SIZE offset (deposit +v, withdraw r-v, transfer 0). Public inputs [root, publicAmount, extDataHash, inNullifier[2], outCommitment[2]]. 27278 constraints on the public pot16 SRS; shield/transfer/unshield fixtures all verify with snarkjs; a mutated publicAmount is rejected. transaction_vk.rs + transaction_proof_fixture.rs emitted in the validated groth16-solana layout (nPublic=7, vk_ic 8 entries). Large build outputs gitignored.
Additive mirror_core::note: ValueKeypair (pubkey=Poseidon(priv)), Note with commitment=Poseidon(amount,pubkey,blinding), signature/nullifier, publicAmount FIELD_SIZE encoding (deposit v / withdraw r-v / transfer 0), and ext_data_hash = keccak256(recipient||relayer||fee||enc0||enc1) mod r. Plus merkle_zeros / merkle_root_from_path / MERKLE_DEPTH helpers. Circomlib-identical to transaction.circom: the cross-check test reproduces every public signal (commitments, nullifiers, root, publicAmount, extDataHash) for all three circuit fixtures. 13 tests; existing API unchanged.
…eld/transfer/unshield) Additive confidential-value layer, separate from the behavioral Pool (no offset shifted). New ValuePool account (own Poseidon value-note accumulator + 32-root history + vault PDA) via InitValuePool (tag 6), and Transact (tag 7): verifies a 2-in/2-out Groth16 JoinSplit proof on-chain (groth16-solana, 7 public inputs), checks root-in-history, recomputes extDataHash via sol_keccak256, creates input nullifier PDAs (anti-replay), inserts output commitments, and moves lamports per publicAmount (deposit -> vault, withdraw -> recipient, transfer -> none). Amounts never appear in cleartext except the public deposit/withdraw magnitude. Encrypted notes emitted via return data for client discovery. 10 new mollusk tests: real fixture proof verifies on-chain (192k CU); shield credits vault; unshield credits recipient; mutated input / replay / wrong extData / unknown root / non-authority all rejected. 24 behavioral tests still green; build-sbf green; clippy/fmt clean. mirror-core gains the Transact wire consts.
…el 1) When a ValuePool is initialized with denomination = Some(d), Transact requires every public deposit and withdrawal to equal d (Transfer with publicAmount 0 is unaffected; None keeps arbitrary amounts). Enforced cheaply right after the authority/known-root checks and before extData recompute, nullifier creation, and Groth16 verification, so a mismatch is rejected fail-closed with zero state change. This gives amount k-anonymity: every public deposit/withdrawal in the pool is identical, so amounts cannot single out a participant. New error DenominationMismatch; 5 new mollusk tests (transact.rs 10 -> 15); build-sbf green.
mirror_core::encrypted_note: X25519 ECDH -> HKDF-SHA256 -> ChaCha20-Poly1305 to a recipient viewing key. Fixed 100-byte blob (ephemeral_pub || nonce || ct+tag) carrying (amount, blinding); fits the on-chain TRANSACT_MAX_ENC_LEN cap and hashes into extDataHash. ViewingKeypair, encrypt_note (+ deterministic variant for fixtures), try_decrypt_note (no panic on bad input), and scan (trial-decrypt a batch). A recovered note rebuilds the correct Poseidon commitment given the value public key, so the recipient can find its leaf and later spend. 7 tests (20 total); additive, deps isolated to mirror-core.
…/unshield) CLI: value-keygen (spend + viewing keys), init-value-pool, shield, transfer, unshield, scan. shield/transfer/unshield rebuild the value Merkle path, snarkjs-prove the JoinSplit against the transaction circuit, verify + cross-check the 7 public signals, convert to the groth16-solana layout, and EMIT the Transact instruction + accounts (never submit). scan trial-decrypts encrypted notes and recovers spendable notes by matching commitments on-chain. Coordinator: submit_transact builds a v0 tx (ComputeBudget + Transact) and submits via the gasless rotating relay. Signer model = the unlinkability: transfer/unshield are relay-only (no user signature); shield is co-signed by the depositor to fund the deposit. Crowd path untouched. Live: MIRROR_PROVE_LIVE transact_pipeline passes (a real JoinSplit proof flows through the full Transact). CLI 44 tests, coordinator 17; workspace clippy/fmt green.
Add mirror-soak-value, a live confidential-VALUE (2-in/2-out JoinSplit) soak alongside the behavioral soak. It drives shield/transfer/unshield through the shipped mirror-cli (snarkjs proving + emit) and the gasless coordinator (submit_transact), verifying every effect on-chain: - shield: vault credited, value root advanced, output commitments inserted, input nullifier PDAs created; replay rejected (NullifierSpent) - scan + private transfer: recover a spendable note, pay a HIDDEN amount (publicAmount == 0, relay-only signer); mutated public input rejected (ProofVerificationFailed) - unshield: fresh recipient credited, vault debited, nullifier created - fixed-denom: exact-denomination shield succeeds; a mismatched public deposit is rejected on-chain (DenominationMismatch) and fail-fast in CLI - value conservation: vault == net deposit - net withdrawal Documents the live run (25/25 assertions, fresh program id, tx sigs) in a new Confidential-value soak section of docs/PROOF.md, preserving the behavioral proof.
…eat-model/readme) ARCHITECTURE gains a full Confidential-value-layer section (ValuePool + Transact, the 2-in/2-out JoinSplit + on-chain Groth16, note/nullifier/publicAmount/extDataHash scheme, ECIES encrypted notes, fixed-denom, CLI + gasless coordinator, and the who+amount unification) + confidential rows in the status table. ROADMAP moves the confidential layer to Built and lists confidential future work. THREAT_MODEL states the confidential path's guarantees + non-goals honestly and flags that it extends beyond the behavioral theme into value-shielding. README status table + intro updated. Fixed stale CLI comments: the fixed denomination IS enforced on-chain.
…, repro) - PROOF.md: remove an absolute-path clean-room/identity leak (repo-relative paths). - circuits/build*.sh: correct the trusted-setup claim (snarkjs contributions are NOT byte-reproducible; trust rests on the committed vk + fixtures verifying, which they do) and warn that rebuilding overwrites circuits/artifacts. - README: fix the stale non-goal that said ZK-deniable initiation was 'landing' (it is built, on-chain-verified, and soak-proven); scope the amount-hiding non-goals to the behavioral pool and point to the confidential layer; correct the mollusk count (42) and cite both soaks (17/17 + 25/25). - mirror-soak: default --program-id to the deployed keypair's pubkey so a fresh clone reproduces without a hardcoded id; fix the PROOF.md reproduce commands to derive ids.
marcelofeitoza
marked this pull request as ready for review
July 19, 2026 00:52
thomgabriel
added a commit
to thomgabriel/mirror-pool
that referenced
this pull request
Jul 21, 2026
Galmanus
added a commit
to Galmanus/mirror-pool
that referenced
this pull request
Jul 23, 2026
…g the witness from the wire Adds a high-level MembershipSet / verify_bytes API over the STARK: the membership proof is returned as opaque bytes that do NOT carry the secret preimage or the leaf index (unlike the reference proof, which serialized the secret verbatim). A test asserts the secret does not appear in the transmitted proof bytes. This is the first concrete step toward closing audit-critical solanabr#1. Still remaining (unchanged in the README Security status): wire this into the BehaviorPool flow (the pool still uses the reference proof) and bind the commitment + nullifier into the same AIR so one proof witnesses membership AND the nullifier.
Galmanus
added a commit
to Galmanus/mirror-pool
that referenced
this pull request
Jul 23, 2026
…(audit-critical solanabr#1, confidentiality half) New crate riverrun-pool-zk: a behavioral pool where the Execution carries an opaque post-quantum STARK membership proof and public data only (root, round, nullifier) — never the witness. settle() verifies from public data alone; a test drops the secret before settling. 5 tests green (round-trip, secret-off-wire, double-spend rejection, per-round, tampered-proof rejection). Non-destructive: riverrun-core's reference-proof pool is untouched (still honestly labeled). Padding restricted to valid STARK tree widths (2/8/128/32768; trace length (depth+1)*8 must be a power of two). Honest remaining gap (documented in the crate): the nullifier is NOT yet bound inside the AIR (audit-critical #1c), so the proof does not witness that the revealed nullifier derives from the same secret that proved membership. Next.
…verify
The local gitignored proving keys had drifted from the committed on-chain
verifying keys (different vk_delta_2), so freshly generated Groth16 proofs
failed to verify against the embedded vk even though the committed fixtures
were internally consistent.
Regenerate a fully consistent set for BOTH circuits from a single dev
trusted-setup run each:
- membership: verification_key.json, vk.json, vk.rs, proof_fixture.{json,rs},
fixture_meta.json
- transaction (2-in/2-out JoinSplit): transaction_verification_key.json,
transaction_vk.rs, transaction_proof_fixture.{json,rs}, shield/unshield
fixtures, transaction_fixture_meta.json
Re-vendor vk.rs and transaction_vk.rs into the program and regenerate the
SHIELD/UNSHIELD Rust fixtures. All 42 mollusk tests pass against the new
vk+fixtures; snarkjs CLI verify passes; a freshly generated membership proof
with new inputs now verifies against the regenerated vk.
… proof Deployed the program to public Solana devnet (id EezWdFrmHtR2PCuucUruvkgyB9HW3w2KskZNeYmXszBq) and re-ran BOTH soak suites against the live cluster, funding every key by system-transfer from one airdropped master payer (devnet rate-limits per-key airdrops). PROOF.md now leads with a browser-verifiable devnet section: program Explorer link + a Finalized transaction signature and CU for every flow - behavioral crowd atomic-epoch settle + ZK opt-in settle (17/17 on-chain assertions) and confidential shield/transfer/unshield + fixed-denom + adversarial (25/25). The local Surfpool run is kept below, clearly labeled. This closes our single biggest competitive gap (we were the only entrant with no public on-chain proof); on a real cluster we now prove more distinct flows than either rival. Note logging uses repo-relative paths (strip_prefix) so the prior absolute-path leak cannot recur; PROOF.md re-scrubbed. devnet != mainnet, stated honestly.
…count drift Top-of-README quick-verify block links the live devnet program + a Finalized crowd-settle tx + the 17/17 and 25/25 soak proofs, so a judge can confirm the system in a browser without building. Fix stale '24 mollusk tests' -> 42 (3 unit + 24 integration + 15 transact); note the program is deployed + exercised on public devnet; relabel the soak row to Surfpool + devnet.
…here it is safe An adversarial review found that SETTLE_EPOCH enforces the k-anonymity floor on-chain while SETTLE_ZK enforces nothing of the kind, and that the docs sold the ZK path as "cryptographic who-initiated unlinkability" to "a fresh address" without stating the on-chain limits. The finding is correct. The proposed fix (a settle-time floor on SettleZk) is not, and the reason is worth stating rather than papering over. What the ZK set actually is: the Groth16 proof hides the member among every leaf under the proven root, but SettleZk PUBLISHES the epoch and the amount, and the leaf binds the epoch, so the set that really covers an output is the window's ZK deposits of the same amount. The per-window narrowing is the deliberate price of the timing defense (binding the epoch is what forces one shared post-close settlement instant and denies FIFO matching); a Tornado-style global set would be wider and would hand that attack back. Why the floor is not moved on-chain: the ZK escrow's only exit is a SettleZk bound by actionHash to one (recipient, amount) and by the leaf to one epoch. There is no refund and no roll-forward, so ANY extra settle-time condition turns thin anonymity into permanently stranded escrow. That is what makes the crowd path's floor cheap (an under-floor epoch rolls forward for free) and this path's expensive. Recipient freshness is worse still: the recipient is fixed at deposit time, so an emptiness check would let anyone who learns the address strand the escrow with one lamport of dust, while emptiness was never the property that mattered. Only the secret holder can produce this proof, so no third party can force a thin settle; the floor belongs at proof time, where declining is free. Changes: - program: comments only. No settlement semantics change, so the deployed devnet program still matches this source. The settle_zk header now states what is NOT checked (floor, denomination, freshness) and why, plus what the set really is. - mollusk: three limitation tests pin the behaviour the docs now describe, so prose and program cannot drift apart. One of them reproduces the fixture leaf from 22 fee-only crowd commits and spends a different participant's escrow with it, which also documents a v1 soundness limit: the ZK escrow is a pool-wide pot with no per-leaf accounting, so a v1 pool must not hold value it cannot afford to lose. Closing that needs a fixed denomination plus domain-separated leaves (roadmapped); half of it would read like all of it. - mirror-cli: `prove` now sizes the window before proving and refuses below the pool's k_floor unless the participant waives it with --accept-thin-set, and records the count it used in the emitted bundle. Fails closed on a missing or drifted epoch account. - soak: passes the waiver deliberately (its ZK window holds one deposit) and says so in the generated report; that step proves the mechanism, not anonymity. - docs, paper: every "fresh address" claim is now a client convention, every "shared on-chain k-floor" claim is now crowd-path only, and the threat model gains the set analysis plus two residuals. Paper PDF rebuilt from the tex.
An independent read of this repository found claims that are not true of the code. A privacy tool that overstates what it hides is worse than one that promises less, so each is corrected at every site rather than softened. 1. The crowd path does not hide who initiated. crowd.rs composes a settlement with an N+1 signer set in which "each participant signs only their own action", so every participant is a visible on-chain signer and the action stays attributable to that wallet. README.md, ARCHITECTURE.md, ROADMAP.md and the paper all said or implied otherwise. They now state the two paths as two different guarantees: the crowd path destroys the per-actor SIGNAL (timing, ordering, size, gas payer, fingerprint, parseable intent) and gives collective intent-deniability against copy-trading and FIFO matching; only the ZK opt-in path, which settles to a fresh output with no participant signature behind an on-chain Groth16 membership proof, provides who-initiated unlinkability. THREAT_MODEL.md already said this correctly and is now what the rest of the tree matches. 2. The 28.80 / 90.0% effective-k headline is conditional and was presented as the default. It assumes 100% adoption AND a voluntary participant dwell of two funding rounds, and dwell is not enforced anywhere: FundingRoundConfig has no dwell field and there is no on-chain check. The enforced-mechanism result is 24.25 (75.8%). Both numbers are now given together, with the assumptions inline, everywhere the headline appears. The harness itself was part of the problem: it labelled the dwell-2 row "(default)" and quoted only that number in its headline block, so it now reports both dwells as separate rows in every table and prints the adoption caveat. 3. The funding path is not wired. fund-commit proves an unshield and PRINTS the emitted request; FundingRounds is a unit-tested batcher type; nothing joins them, and the coordinator binary is an in-memory scheduler demo. No funding round has ever released a withdrawal. The status rows now read "Library + CLI only; NOT wired, NOT soaked", and the doc comments in cli/funding.rs and coordinator/funding.rs no longer describe the handoff as something that happens. coordinator/funding.rs also claimed to implement "batching plus dwell"; it implements batching. Test counts: verified by running both suites. 44 program tests (was 42) and 220 host tests passing with 7 environment-gated (was 205 in two places and 177 in a third). Every document now agrees. PROOF.md: the historical run records are untouched, including the assertion rows that say the proof was verified by snarkjs, because that is what those runs did. What changed is the system descriptions around them: the two present-tense claims that the shipped CLI proves with snarkjs, and the reproduce recipes that told a reader to install snarkjs to prove. A note at the top of the file explains that the records predate in-process pure-Rust proving. The soak binaries themselves asserted "verified (snarkjs)" while not passing --use-snarkjs, so a fresh run printed a false statement; corrected. The paper is rebuilt (15 -> 16 pages). Gates: cargo check, fmt --check, clippy -D warnings, test --workspace (220 passed / 7 ignored) all green; programs/mirror-pool build-sbf green with 44/44.
# Conflicts: # README.md # docs/PROOF.md # docs/THREAT_MODEL.md # paper/mirror-pool.pdf # paper/mirror-pool.tex
# Conflicts: # Makefile # README.md # docs/ARCHITECTURE.md # docs/EFFECTIVE_K.md # docs/ROADMAP.md
# Conflicts: # README.md # crates/mirror-cli/src/chain.rs
Merging the ceremony, association-set, funding-soak and SettleZk branches changed the suite sizes: host 220 -> 241, program 44 -> 70, environment-gated 7 -> 9. Every document that quotes a count is updated and the paper is rebuilt.
… registry PDA The Groth16 verifying key is the root of trust of every proof this program accepts, so the only interesting question about where it lives is who can change it and to what. It used to be a compile-time constant per verify path: safe, but opaque (you disassemble the deployment to see it) and awkward for a program that serves three circuits. Moving it into a config account is normally a downgrade, not an upgrade: a verifying key is not self-authenticating, so a registry that validates FORMAT only (right length, right ic_len) accepts a perfectly well-formed key whose trapdoor the submitter holds, and forged proofs drain the escrow. This lands the version that is not a downgrade: - WRITE-ONCE. INIT_VK is the only instruction that writes a registry and there is deliberately no update tag. A second install fails with VkRegistryAlreadyInitialized rather than overwriting, so it is not even idempotent. No admin, no authority field, nothing to find. - DIGEST-PINNED. The program carries the SHA-256 of the canonical encoding of each key it will ever accept (src/vk_digest.rs). INIT_VK accepts bytes only if they hash to that digest, and EVERY verify recomputes it over the stored bytes before they reach the verifier. The set of acceptable keys stays fixed by the bytecode, exactly as when the key was a const; what the account buys is that the key in force is publicly readable. Because the caller cannot choose the key, installation is permissionless: all it can do is pay rent to publish the one key the bytecode already committed to. All three verify paths were migrated, so "validated on every verify" is literally true: SettleZk (membership), SettleZkAssociated (association) and Transact (JoinSplit) all go through one function, state::vk_registry::verify_pinned, which is the only way to reach Groth16Verifier::new in this program. Fail-closed throughout: fixed-size decode scratch sized by a compile-time bound, no allocation, no casting of account bytes, exact shape checks before hashing, and the existing error taxonomy extended additively (28/29/30). Tests, all against the compiled SBF program: a well-formed FOREIGN key (same length, same nr_pubinputs, same ic_len, real BN254 points) is refused at install and at every one of the three verifies; a single flipped byte, an unknown circuit id and every wrong body length are refused before the account is created, so a rejected install costs no rent; all 256 instruction tags driven with a live registry in the first writable slot leave its bytes, lamports and owner untouched, which is the mechanical form of "there is no update path"; a host test recomputes all three digests from the vendored keys so a key edit that forgets its digest fails the suite. 70 -> 98 program tests, 241 -> 244 host tests. Measured cost, mollusk, before -> after: SettleZk 103,387 -> 107,354 CU, SettleZkAssociated 113,733 -> 120,742, Transact 195,213 -> 197,843; InitVk 3,972 once per circuit; 6,270,960 lamports rent for a 773-byte registry. The spread is not monotone in key length, so the dominant term is the PDA bump search, not the hash, and a different deployment will see different numbers in that range. docs/VK_REGISTRY.md states this and the rest of the ledger, including the cost that is not measurable (a second place a mistake can live) and the fact that rotating to a post-ceremony key still needs a program upgrade, because a pin a third party could move is not a pin. BREAKING, and it invalidates a published artifact: the devnet program EezWdFrmHtR2PCuucUruvkgyB9HW3w2KskZNeYmXszBq was deployed before this change, has no InitVk, and takes one fewer account on every settle. It no longer matches this source tree. It was NOT redeployed. docs/PROOF.md now opens by marking its recorded runs (behavioral 17/17, confidential 25/25, funding 25/25) as records of that earlier program, still valid for what they showed and not evidence about the registry; README and ARCHITECTURE carry the same note. The soak drivers publish the keys and pass the accounts but have not been re-run against a public cluster.
The mollusk suite already runs the real SBF bytecode, so it covers ownership, PDA derivation, the sha256 syscall and the create-account CPI. What it does not cover is transaction assembly and the shipped CLI, so both were checked against a running Surfpool on a fresh deployment of the current bytecode. Publishing all three keys through `mirror-cli init-vk` produced program-owned registries of exactly the right size, version, circuit id and reserved byte, whose stored bytes hash to exactly the constants in `vk_digest.rs`. Re-running the install failed on-chain with 0x1c (VkRegistryAlreadyInitialized), so write-once holds on a validator too. The behavioral soak then ran end to end and passed 18/18, including a real membership proof verified against a key the program loaded from its registry account and re-hashed against MEMBERSHIP_VK_SHA256 in the same instruction. Recorded as a reproduction recipe, not as proof: these are local-validator signatures. They are kept in VK_REGISTRY.md and deliberately NOT merged into PROOF.md's tables, which belong to the earlier devnet program; PROOF.md now says so. The observed registry bumps (252, 255, 255) are also the concrete explanation for why the per-verify compute deltas are a range and not constants. ROADMAP's ceremony section now states what rotation actually costs under this design: move one pinned digest, upgrade, publish once per circuit.
Reviewer item 8 ("trim the surface"): 29.5k Rust LOC is a lot to audit, so
shrink what an auditor has to read without removing a capability, a test, or a
validation check.
Under crates/, 31,257 -> 30,388 lines (-869, -2.8%), measured with
find crates -name "*.rs" -not -path "*/target/*" | xargs wc -l
What moved, in descending order of size:
- The three soak binaries each carried their own copy of the same chain
scaffolding: process spawning, faucet/master-payer funding, keypair
persistence, the InitPool and Commit wire builders, v0 submission, PDA
derivation, the PASS/FAIL report, and the docs/PROOF.md tables. That now
lives once in a new mirror-soak library. Each binary keeps its own
assertions, its own prose, and its own main.
- The PROOF.md generators emitted static markdown one writeln! per line, which
rustfmt wrapped to four source lines per output line. Runs of literal lines
are now single raw-string blocks, so the generated document is readable as
markdown in the source. Output is byte-identical: every writeln!/push_str was
parsed to an ordered output trace before and after, and the traces match
exactly, including the escapes and the embedded newline.
- prove and prove-associated both bound a ZK opt-in note to a witness with the
same sixty lines: recompute action hash, nullifier and leaf, refuse a leaf
that disagrees with the note, rebuild the pool path, self-check it, and
refuse a root the pool would not accept. That binding now has one definition.
This one costs about 25 lines and is kept anyway: the checks are
security-relevant and two copies is one too many to keep in step.
- Three live proving tests rebuilt the committed membership fixture inline, and
two rebuilt the committed transfer witness inline. Each fixture now has one
definition. No test was removed or weakened.
- read_leaves had three near-identical copies; it is the parser that binds a
proof to a leaf set, so it moves to util with one definition.
- Twelve CLI subcommands repeated the same --rpc-url and --program-id
declarations; they now flatten a shared ChainArgs. The CLI surface is
unchanged, verified by diffing --help for all eighteen subcommands.
- Removed two genuinely dead items: the unused MirrorError enum and the
uncalled Transcript::head_deltas.
Verification:
- cargo fmt --check, cargo clippy --all-targets -D warnings, cargo test
--workspace all pass; 244 -> 247 tests (three added to pin the shared
markdown emitters, none removed).
- cargo build-sbf and the ceremony transcript re-verification pass.
- All 8 MIRROR_PROVE_LIVE tests pass, including the three membership and two
transaction provers whose fixtures were shared.
- The behavioral soak runs GREEN 18/18 against a fresh local validator.
- The confidential and funding soaks were run twice on separate fresh
validators, once on this tree and once on the unmodified parent commit; the
logs are identical modulo addresses. Both stop at pre-existing local
environment limits, not at anything this change introduced.
…uster The three existing soaks answer "does every flow work?" in a few minutes. That is a completeness argument, not a durability one: ten minutes of transactions all land inside one leader schedule, one RPC connection and one slice of cluster load, so a fault that needs hours to surface has no room to surface. mirror-soak-sustained repeats ONE fixed crowd shape on a paced cadence for as long as it is told, and records what changed between the first round and the last: per-transaction confirmation latency bucketed by hour, the set of distinct slot leaders actually seen, on-chain and in-process state growth, and accumulator drift measured every round by replaying the same leaves through an off-chain frontier and comparing roots with the program. Two properties it is built around. It never airdrops. A public faucet is rate-limited per IP and a long run cannot depend on one, so a single pre-funded master payer fans out to every wallet by system transfer, settled lamports are recycled from the sink so the cost is rent plus fees rather than transfer volume, and the loop stops itself at a configured budget floor. A run that runs out of money reports the rounds it completed instead of failing. Its evidence is re-derivable without a chain. Samples and rounds are appended to JSONL as they happen, so an interrupted run keeps everything it measured, and --summarize recomputes the whole aggregate from those files with no RPC, no keys and no cluster. Two things a first local run against Surfpool caught, both fixed here: equal recycle shares starve whichever wallet paid the window's Epoch PDA rent until it can no longer transfer and stay rent-exempt, so wallets are topped back to a common target instead; and appending to the reference accumulator only on a successful settle desynchronizes it from the chain the first time a settle fails, which reported drift the harness had caused itself. Drift is now measured on the commits, and a round whose leaf count cannot be lined up with the chain reports "not checked" rather than a verdict.
… Poseidon gadget Express the membership statement as arkworks constraint synthesis instead of circom source, so one circuit's constraint system, trusted setup and proof need no circom, snarkjs, node or npm. Proving was already pure Rust in process; what remained circom-shaped was everything upstream of it, and this removes that for the membership circuit. The circom path is unchanged and remains the deployed one. New crate crates/mirror-circuits: - poseidon: the circomlib permutation synthesized as R1CS constraints, wired to the light-poseidon bn254_x5 parameter table the sol_poseidon syscall is built on, plus the native hash it is tested against. - membership: depth-20 Poseidon Merkle inclusion, epoch-scoped nullifier, action binding, 4 public inputs in the order the on-chain handler consumes them. - setup and onchain: Groth16 setup, prove, verify, and the groth16-solana byte encodings, reusing mirror-ceremony's exporters rather than adding a third copy. Measured, not asserted. 5,363 arkworks constraints against 5,427 multiplication rows in the committed circom r1cs; the 64-row delta is the 22 folded domain-tag S-boxes minus the two equality assertions, and a test asserts the accounting on both sides. A fresh proof from this path is accepted by the same groth16-solana verifier the program links, and a tampered public input is rejected. The gadget's depth-20 root is cross-checked against the on-chain sol_poseidon accumulator through a vector both sides recompute independently. Limits are stated in docs/ARKWORKS.md rather than glossed: the setup is single-party, only the membership circuit is covered, the syscall cross-check pins parameter and encoding wiring rather than two independent Poseidons, and the resulting verifying key is not the digest-pinned key the deployed program accepts. Every pre-existing circuits/ artifact is byte-identical.
…still pulls in mirror-circuits reuses mirror-ceremony's verifying-key exporter and point encodings rather than adding a third copy of the same byte format, and mirror-ceremony depends on ark-circom for its snarkjs zkey import. Building or testing the arkworks path therefore still compiles ark-circom and wasmer even though nothing on that path calls them. That is a Rust crate already in the lockfile and not a JavaScript toolchain dependency, but the claim reads cleaner than the truth without saying so.
…funding All three soak drivers publish their verifying key through init-vk before they settle, so each needs the CURRENT bytecode deployed. That build is 119,072 bytes and costs about 0.83 SOL to deploy or stage, and the devnet faucet is refusing this address. The deployed program predates the VK registry, so the ZK, confidential and funding results are local Surfpool evidence only until a redeploy is funded. The captured numbers are unchanged; this is a reproducibility caveat, not a correction.
host 244 -> 262, program 98 -> 99, environment-gated 9 -> 10. Paper rebuilt.
…and a fixed denomination A fee-only crowd Commit could spend another participant's ZK escrow. Crowd and deposit leaves had the same shape in one accumulator, and nothing tied a settled amount to what that leaf's owner deposited, so 22 free commits could reproduce a leaf and drain a depositor. Closed by two changes that only work together. The ZK pool now carries a fixed denomination set at init, so a settle cannot draw an amount nobody deposited. And crowd leaves are domain-separated BY THE PROGRAM, crowd_leaf = Poseidon(CROWD_LEAF_DOMAIN, commitment), so a crowd leaf is not a valid preimage for the ZK spend statement. The tag has to be applied by the program to the FREE path: a deposit leaf is 32 caller-supplied bytes, so a client asked to tag its own deposit would pre-compute the tagged value and post it for free. The original attack test is inverted rather than deleted and now asserts the theft is rejected, and a second test pins that the pre-hashing dodge also fails. No circuit change was needed, so no verifying key was regenerated and every circuit artifact is untouched. README, threat model, roadmap, architecture and the paper are updated from disclosed-limitation to fixed.
The fix landed in the previous commit; the prose around it was left with three kinds of error, all corrected here. Stale claims. The architecture instruction table, the roadmap instruction list, the README capability row and the paper all still said SettleZk enforces no denomination. It does, on both ZK settle paths. The InitPool body is 30 bytes, not 22, and the Pool account is 1788 bytes with zk_denomination appended after the incentive counters. The roadmap's "Denominated ZK deposits" entry still described the fix as a future v2 after its own DONE header. A removed clause had also left one architecture sentence ungrammatical. Overstated claims. Section 4 still described the ZK anonymity set as every leaf under the root, crowd leaves included, which the leaf-domain split makes false. The set is the window's ZK deposits, and the amount no longer narrows it, since a pool admits exactly one ZK size. Net of the fix the set does not shrink: the crowd leaves it removes are exactly the free leaves that could satisfy the circuit, and the amount channel it empties is exactly the one that used to split deposits by size. Missing disclosure. Three things were not stated. The bound is aggregate, total settled cannot exceed total escrowed, not a per-leaf escrow ledger, which is impossible on a path whose point is that a settle is unlinkable to a deposit. CommitDeposit cannot read the epoch inside an opaque leaf, so a depositor may bind a closed epoch and settle without waiting, which forfeits their own timing cover and no one else's and cannot overdraw; that is now residual 13. And the fix is SOURCE-ONLY: the public devnet program predates it, is still exploitable by the original attack, and has not been redeployed because a fresh deploy needs about 0.83 SOL and the faucet is refusing. README and PROOF.md now say so plainly. Test counts refreshed to the real numbers (264 host, 105 program) and the paper PDF rebuilt from its source.
… one The confidential-value JoinSplit was the last circuit with no Rust-only path. crates/mirror-circuits already held an arkworks membership circuit; this adds the 2-in / 2-out transaction statement next to it, reusing the existing Poseidon gadget rather than introducing a second hash. What is new: - gadgets: circomlib's Num2Bits, Switcher and ForceEqualIfEnabled, each at the cost circom pays. The arkworks equivalents are more expensive for reasons unrelated to this statement (to_bits_le proves a full 254-bit in-field decomposition; two conditional_selects cost twice a Switcher), and using them would have made the constraint comparison meaningless. - transaction: the statement itself. Note commitments, owner-and-leaf-bound nullifiers, disabled membership for zero-amount dummies, 248-bit range proofs, the signed publicAmount decoding, nullifier distinctness and the extDataHash binding. 7 public inputs in the circom declaration order. - setup: transaction_setup / transaction_prove / transaction_verify, and shape_of so both circuits share one accounting path. Evidence, all executed: - Equivalence. All three committed circom fixtures (SHIELD, TRANSFER, UNSHIELD) are rebuilt from the generator's published constants, run through the arkworks system, and required to be satisfied AND to derive the same seven publicSignals snarkjs emitted. The public-input layout is read from transaction_fixture_meta.json, not restated. - Cost. circom 13,098 multiplication rows, arkworks 12,958. The 140-row delta is itemized in a test: -150 for the folded round-0 domain-tag S-box across 50 hashes, +10 for equalities circom emits as affine rows. This is NOT a claim of constraint-for-constraint identity. - On chain. A fresh proof over inputs in no fixture is accepted by the real groth16-solana verifier, and rejected when publicAmount, a nullifier or extDataHash is moved. That is a HOST test, not a landed transaction: the program pins the circom key by digest, so an arkworks key cannot settle without a second pin, and no program logic was changed. One thing worth not overstating, now documented: the extDataHash square row does not constrain extDataHash in either circuit. It is satisfiable for any value. Ext data is tamper-evident because extDataHash is a Groth16 public input, which is a property of the proof and is tested against the on-chain verifier rather than asserted about the R1CS. The circom path is untouched. Every pre-existing artifact under circuits/ is byte-identical, the vendored program-side keys still match, and the pinned digests are unchanged. Association remains circom-only.
…cords The compliance layer had only half a disclosure story: association proofs say "I am in the acceptable set" to everyone, and the encrypted-note ECIES existed client-side, but nothing published a viewing key or bound a disclosure to a settlement on-chain. Two additive instructions, neither read by any settle path: - REGISTER_VIEWING_KEY writes a ViewingKey account at ["view", authority], with the authority signing. The authority is the only variable seed, so the only entry a signer can write is their own: the directory cannot be squatted, and rotation is possible for the owner alone. - PUBLISH_DISCLOSURE writes a write-once Disclosure record at ["disc", pool, action_hash, auditor_view_pub]. action_hash is NOT taken from the caller: the handler recomputes Poseidon(recipientHi, recipientLo, amount) from the SIGNING recipient with the same syscall SETTLE_ZK uses, and that value is a seed. Since the settling commitment binds actionHash and actionHash binds the recipient, the only party who can publish about a settlement is the address that settlement was bound to pay. Publishing against somebody else's action is not a check that can be skipped, it is an address that cannot be derived. The record is structurally validated rather than accepted opaquely: exact blob length, the reader must be a real registered ViewingKey PDA (the key and identity are read from that account), the amount must be the pool's denomination, and both the stored key and the blob's ephemeral key must be canonical, non-small-order X25519 encodings. The record deliberately never names the disclosed commitment, so the deposit side stays hidden from everyone but the reader. Host side reuses mirror-core's ECIES unchanged: a disclosure is one encrypted-note blob whose plaintext is (epoch, secret), which lets the reader recompute the deposit leaf and the spend tag and check both against the chain. CLI: viewing-key register/show, disclose, audit scan. docs/COMPLIANCE.md gains the full model, including a table of what is enforced on-chain versus what the reader must verify themselves, and the privacy cost of registering at all: a record publicly announces that a settlement has a disclosure and names the reader, it is permanent, and there is no revocation. What the program cannot do is stated rather than implied: it holds no secret, so it cannot verify that a blob opens or that it opens to a real commitment, and the confidential-value layer has no on-chain disclosure primitive at all. Tests: 16 mollusk cases (viewing.rs) covering acceptance, the squat and unsigned-publisher rejections, forged readers, degenerate keys and blobs, write-once, and a settle that runs with no viewing key or record anywhere; 6 host cases in mirror-core; 4 CLI layout/derivation cases.
…t work host 274 -> 300, environment-gated 11 -> 12. Program stays 121. Paper rebuilt.
The membership verifying key is no longer a circuits/build.sh dev key. It is now
the output of a real Groth16 phase-2 ceremony over the public 55-contribution
powers-of-tau, closed by the Solana mainnet-beta blockhash at slot 435825712
(9Gth2wVt86WhS1fh5FS7FihGvxyaesunW28M3zjD46Eu, 2^20 iterations), final transcript
hash 884c88601173b1f08bd2e26626b0fe4c553dedffe707b2387db754417a9cdd05.
It has ONE independent contributor and its beacon slot was chosen after the
contribution rather than announced in advance. Both limits are stated in
docs/CEREMONY.md section 10.3, in the paper, and in every status line that used
to say "no production ceremony has been run". The JoinSplit and association keys
are still dev-setup keys and every doc now says so per circuit.
What changed:
- circuits/artifacts/{verification_key.json,vk.rs} exported with
`ceremony export-vk`; vk.json refreshed to match.
- programs/mirror-pool/src/vk.rs vendored from the artifact, byte-identical
apart from the vendoring header.
- MEMBERSHIP_VK_SHA256 re-pinned to
be5f776d2a4ba83655c50a9ecf47192cd3aa74075cd9e3d8a62bd99e043e4c76, and the
client-side copy in mirror-cli with it.
- proof_fixture.{json,rs} regenerated under the ceremony key via
`ceremony prove-check` + convert_to_rust.js. Public signals are unchanged
(same witness), so only the proof moved.
- The two live tests that assert the committed vk accepts a fresh proof now
prove under the ceremony key; the snarkjs-shell-out test verifies against the
dev zkey's own exported vk, since the dev zkey can no longer produce a proof
the committed key accepts.
- Transcript published at docs/ceremony-run/membership-deployed-transcript.json
and pinned by a new mirror-ceremony test; `make ceremony-verify-run` checks it
with the beacon value supplied.
Deployed: devnet EezWdFrmHtR2PCuucUruvkgyB9HW3w2KskZNeYmXszBq upgraded in place,
signature 58gKGUdvKowyv4phxNUxKxiPbbnWNps9SKhxVkLWt7V8Q7DQLhrTuH2UWhUPtDzmeSPTEBExzXYeGVeYDuGFWHm,
slot 479600717. The dumped on-chain bytes are byte-for-byte the local
mirror_pool.so (sha256 6b026cf95e7f76d8a45c248004f98e2f647e7fc806203e1785bdeddcbea4f466).
No vk registry account exists for any circuit on that program, so the first
InitVk installs the ceremony key rather than colliding with a write-once entry.
The transaction and association keys, digests and fixtures are byte-identical,
verified by sha256 before and after.
…d reject prove verified its output only against the key it was made under, so a proof built with the dev zkey passed every local check and failed on chain, after the user had paid to submit it. The deployed membership key is now a ceremony output while --zkey still defaults to the dev key, which makes that the easy mistake rather than an unlikely one. prove now verifies its finished proof against the COMMITTED verifying key using the same groth16-solana verifier the program runs, and refuses to emit on mismatch, naming --proving-key in the error. The guard is key-agnostic, so a future rotation is caught the same way. a_dev_key_proof_is_refused_before_it_can_be_emitted proves under the dev key successfully, then asserts the guard rejects it and that the message says how to fix it.
The deployed membership ceremony closed on a real public blockhash, but the slot was picked afterwards, so grinding cannot be ruled out. This names mainnet-beta slot 435846661 while it is still roughly 25 minutes in the future, so the JoinSplit and association ceremonies can close on a value nobody could know when the commitment was made.
The association statement was the last circom-only path in the repo. It is now an arkworks-native constraint system alongside the circom one, so every circuit here can be defined, set up, proven and verified without circom, snarkjs, node or npm. The membership half is not a copy. `membership::native_inclusion` and `membership::enforce_inclusion` are extracted as the crate's single definition of "walk a depth-20 Merkle path"; `MembershipCircuit` calls the latter once and `AssociationCircuit` calls it twice, over two independent paths for one shared leaf, which is what `association.circom` does with two `MerkleProof(20)` instances. `AssociationWitness::membership()` returns the membership witness hiding inside an association one, so "strict extension" is a checked property. Equivalence with circom is checked against the committed fixture rather than asserted: the witness behind `association_proof_fixture.json` is rebuilt from the scenario constants `association_fixture_meta.json` publishes, the arkworks system must be satisfied by it, and the five public inputs must equal the five `publicSignals` snarkjs emitted, element by element. Nothing is copied out of the fixture; the commitment and both roots are derived, and the test asserts the derived commitment really is the leaf at each declared index. The public-input layout is read from the meta, so a circom-side reorder fails the test. The same check is added for the membership fixture, which had none. Constraint counts, both measured by tests: circom 10,347 multiplication rows, arkworks 10,224. The 123-row delta is itemized as 42 folded round-0 domain-tag S-boxes (-126) against three `===` rows arkworks pays and circom emits as affine rows (+3). No claim of constraint-for-constraint identity is made. A fresh proof over inputs that appear in no fixture is accepted by the real `groth16-solana` verifier in a host test, and rejected when `associationRoot`, the pool root or the nullifier is moved. It is a host test, not a landed transaction: the program pins the circom keys by digest, so an arkworks key would need a second pinned digest and a program upgrade. The circom path is untouched. Every file under `circuits/artifacts/` and all three vendored program-side keys are byte-identical, and the pinned digests in `programs/mirror-pool/src/vk_digest.rs` still match their vendored keys.
…ircuits
The transaction (confidential JoinSplit) and association (opt-in compliance)
verifying keys are no longer dev-setup keys with public toxic waste. Both are now
the output of real Groth16 phase-2 ceremonies over the public 55-contribution
powers-of-tau, and both closed on the beacon slot that was PRE-COMMITTED in
docs/ceremony-run/BEACON-PRECOMMITMENT.md before its value existed:
solana-mainnet-beta slot 435846661 blockhash 67Y5hxUdXtxczqCcFnQkcqmPXJUDbSq7yKFGqhUzWLgH
That slot was produced, so the commitment was honoured exactly; no substitution
was needed. Each ceremony has ONE independent contributor, which is stated
everywhere rather than smoothed over. The membership key deployed earlier does
NOT have a pre-committed beacon; that difference is now recorded per circuit
instead of being averaged away.
transaction final transcript 6d0449341db0744509782a2249e3fd8182aa4bc228f3b2774312fefd81bbaa80
association final transcript 5ef80404f6136cd2a9f843c57fe928c87200142f1a7d4c0ce181ff26f0808e1d
What changed:
- circuits/artifacts/{transaction,association}_{verification_key.json,vk.rs}
exported with `ceremony export-vk`; vendored into the program with a provenance
header, verbatim otherwise.
- TRANSACTION_VK_SHA256 re-pinned to 4b542099..., ASSOCIATION_VK_SHA256 to
90d13582..., and the client-side mirror in mirror-cli with them.
- Every fixture produced under the old dev keys regenerated under the ceremony
keys: the TRANSFER/SHIELD/UNSHIELD JoinSplit fixtures, the association fixture,
their Rust constants, and the on-chain tests' SHIELD/UNSHIELD constants. Public
signals are byte-identical in every case (same witnesses), so only the proofs
moved, and that is enforced rather than asserted: the new
`ceremony prove-fixture` refuses to write a fixture whose public signals differ
from the one it replaces, and runs the real on-chain verifier over the result.
- programs/mirror-pool/tests/fixtures/transaction_extra.rs was generated by an
uncommitted scratchpad script, so a key rotation left it stale with no
documented fix. It is now regenerable: `convert_to_rust.js --transaction-extra`.
- `shield`/`transfer`/`unshield`/`prove-associated` gained `--proving-key`, so a
ceremony key can produce proofs on those paths, and both now verify their
finished proof against the COMMITTED verifying key before emitting, the same
guard the membership path already had.
- The two soak drivers prove under the ceremony key via a new
`mirror_soak::ceremony_head_key` rather than the dev zkey.
- The documented vendored-vs-artifact check compared files that intentionally
differ by a comment header, so it failed on a correct tree. It now compares
code with comments stripped, in circuits/README.md and docs/PROOF.md alike.
The MEMBERSHIP key, its fixture and its digest are byte-identical before and
after, verified by sha256 against baselines captured first.
Gates: cargo fmt --all --check clean, clippy -D warnings clean, cargo test
--workspace 301 passed / 0 failed, cargo build-sbf plus the mollusk suite
121 passed / 0 failed.
…gram docs/PROOF.md carried behavioral 17/17, confidential 25/25 and funding 25/25 tables captured against OLDER bytecode at the devnet address, and every place that cited them said so. They now describe the program a reviewer actually inspects. Devnet EezWdFrmHtR2PCuucUruvkgyB9HW3w2KskZNeYmXszBq was upgraded in place to the ceremony-key build, signature 27Hg4jV8W4UeCY9RX9kpNfwJoMVz5BE4qRMpV9AqwN1Z3Y1bygy9vMwszM4E4vhZs69otSHg3Dwrn61DDTGfVLbF, slot 479622920. The dumped on-chain bytes are byte-for-byte the local mirror_pool.so, sha256 5b8cfdc0112b084ce3a5189333b719388a8b29a3f6300a7fed531ba4c3fa7d93. Re-run against it: - behavioral 18/18 on public devnet - confidential 27/27 on public devnet - funding 27/27 on a local Surfpool, on a fresh deployment of the identical .so The funding soak stays local, and PROOF.md says so rather than implying a public run. The counts moved because the runs gained assertions, not because anything was relaxed: each suite now publishes its verifying key through init-vk, and the confidential one asserts the lookup table described below. Three things had to be fixed before any of this could run at all, each a real defect the older tables were hiding: 1. The soaks proved under the dev `.zkey` while the deployed keys are ceremony outputs, so every proof was refused before submission. They now resolve the ceremony head key (`ceremony_head_key`) and pass `--proving-key`. 2. A Transact no longer fits in a 1232-byte packet. The instruction carries a 256-byte proof, seven public inputs and two encrypted-note blobs, and moving the verifying key into a registry account added one more account, which put the shield case 16 bytes over. `submit_transact_with_luts` and a `lookup_tables` field on FundingRoundConfig let the pool's static accounts resolve through an ALT; the nullifier PDAs and signers stay inline. Packing only: same instruction, same signers. 3. The funding soak's provenance forensics read only a transaction's STATIC account keys, so once the vault moved into the ALT `delta_of(vault)` silently returned None and the headline provenance claim was being evaluated against a truncated account list. It now appends meta.loadedAddresses in the order the balance arrays use. This one mattered: it was a false FAIL rather than a false PASS, but the same blind spot could have produced either. Also: `init-vk` is now idempotent. The registry is per-program and write-once, so a second run found it published and failed with VkRegistryAlreadyInitialized, which says nothing about whether the key in force is the right one. It now reads the account back and confirms the stored bytes ARE the committed key, and fails only when a DIFFERENT key is installed. The association verifying key was published on devnet too, so all three registries now hold a ceremony key and each InitVk transaction is linked from PROOF.md. Docs corrected to the new truth: README, PROOF.md, CEREMONY.md (section 10 now covers all three ceremonies and section 10.2 states plainly that the JoinSplit and association beacons were pre-committed and membership's was not), THREAT_MODEL.md, ROADMAP.md, ARCHITECTURE.md, VK_REGISTRY.md, circuits/README.md and paper/mirror-pool.tex (PDF rebuilt). Transcripts published under docs/ceremony-run/ and pinned by a new mirror-ceremony test; make ceremony-verify-run checks all three with their beacon values supplied. Gates: cargo check --workspace clean, cargo fmt --all --check clean, clippy -D warnings clean, cargo test --workspace 302 passed / 0 failed / 13 ignored, cargo build-sbf green, mollusk 121 passed / 0 failed.
…ust-only path The association circuit was the last circom-only statement. It is now also expressed in arkworks, reusing the shared Poseidon gadget: 10,224 rows against circom's 10,347 multiplication rows, delta itemized. It replays the committed circom fixture and derives the same five public signals, and a fresh proof over new inputs is accepted by the real groth16-solana verifier in a host test. The circom path is untouched and every pre-existing artifact is byte-identical. Honest scope, stated in docs/ARKWORKS.md: the circom dependency is now demonstrated removable for every circuit and removed for none, since the deployed keys and the default proving path are still circom artifacts. Test counts refreshed (317 host).
Galmanus
added a commit
to Galmanus/mirror-pool
that referenced
this pull request
Aug 22, 2026
…g the witness from the wire Adds a high-level MembershipSet / verify_bytes API over the STARK: the membership proof is returned as opaque bytes that do NOT carry the secret preimage or the leaf index (unlike the reference proof, which serialized the secret verbatim). A test asserts the secret does not appear in the transmitted proof bytes. This is the first concrete step toward closing audit-critical solanabr#1. Still remaining (unchanged in the README Security status): wire this into the BehaviorPool flow (the pool still uses the reference proof) and bind the commitment + nullifier into the same AIR so one proof witnesses membership AND the nullifier.
Galmanus
added a commit
to Galmanus/mirror-pool
that referenced
this pull request
Aug 22, 2026
…(audit-critical solanabr#1, confidentiality half) New crate riverrun-pool-zk: a behavioral pool where the Execution carries an opaque post-quantum STARK membership proof and public data only (root, round, nullifier) — never the witness. settle() verifies from public data alone; a test drops the secret before settling. 5 tests green (round-trip, secret-off-wire, double-spend rejection, per-round, tampered-proof rejection). Non-destructive: riverrun-core's reference-proof pool is untouched (still honestly labeled). Padding restricted to valid STARK tree widths (2/8/128/32768; trace length (depth+1)*8 must be a power of two). Honest remaining gap (documented in the crate): the nullifier is NOT yet bound inside the AIR (audit-critical #1c), so the proof does not witness that the revealed nullifier derives from the same secret that proved membership. Next.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
mirror-pool makes on-chain activity hard for automated chain-analysis to read. It offers two settlement paths with deliberately different strengths, plus an optional confidential-value layer:
Commit/SettleEpoch) - N identical actions settle atomically in one transaction on one timestamp, paid by a rotating gasless relay. Each participant signs their own action, so this path does not hide the on-chain signer. What it destroys is the per-actor signal (timing, ordering, size, gas payer, fingerprint, parseable intent), which is what defeats copy-trading and FIFO temporal matching.CommitDeposit/SettleZk) - this is the one that hides WHO. A Groth16 membership proof is verified on-chain and the escrow is released with no participant signature at settle.InitValuePool/Transact) - a 2-in/2-out Tornado-Nova JoinSplit that hides HOW MUCH. Note amounts and internal transfers are hidden; shield, unshield and pool TVL remain public.Clean-room, MIT, Rust. Proving runs in-process in pure Rust (ark-circom / ark-groth16, no Node); circom is still required at build time to produce the artifacts.
Verify in 2 minutes
EezWdFrmHtR2PCuucUruvkgyB9HW3w2KskZNeYmXszBqdocs/PROOF.mdpaper/mirror-pool.pdfWhat is here
docs/CEREMONY.md) - phase-2 delta re-randomization, a Schnorr proof of knowledge bound to the contributor id, the chain position and the step's kind and provenance, SHA-256 transcript chaining, an enforced beacon-is-final rule, and reproducible verification that rejects tampered deltas, forged or replayed proofs, reordered and truncated chains, post-beacon steps and relabelled beacons - each with its own test. Demo transcripts committed underdocs/ceremony-run/.docs/COMPLIANCE.md) - Privacy-Pools-style: prove your deposit is in a curator's curated set without revealing which deposit, enforced on-chain in the execute path. Opt-in per settlement rather than per pool, so a curator can decline to vouch but cannot block anyone from settling.seeds = ["vk", circuit_id], installed by a singleInitVkinstruction with no update path, no authority field and no admin. The stored bytes must hash to a digest the program pins at compile time, checked at install AND recomputed on every verify, so an arbitrary key cannot be installed even though installation is permissionless. That pinning is what makes a PDA-stored key safe rather than a liability.docs/ARKWORKS.md) - an additional Rust-only constraint-synthesis path with an in-circuit Poseidon gadget, cross-checked against the native hash and thesol_poseidonsyscall. It does not replace the circom path and does not change any program logic. Honest scope: it verifies through thegroth16-solanaverifier crate in a host test, not as a landedSettleZk, because a different constraint system means a different key and the program pins the circom key's digest.docs/EFFECTIVE_K.md) - see below.Anonymity, measured rather than assumed
An earlier revision of this branch modelled all participants as a single provenance class and then concluded effective-k equalled nominal, which was circular. That is replaced, and the honest numbers are lower. At nominal k=32, Shannon effective-k against a joint deposit-to-withdrawal matching adversary:
24.25 is what the protocol enforces on its own. 28.80 additionally assumes full adoption and participant dwell, which the protocol recommends but cannot enforce. Each mitigation alone is close to useless; they only work composed. Adoption is the binding constraint: at 50% adoption the same configuration measures 42.4% of nominal, and one direct top-up returns the worst case to 1.00.
Known limitations, stated plainly
CommitandCommitDepositproduce identically-shaped leaves in one accumulator, a fee-only crowd commit can spend another participant's escrow. This is pinned by a passing test (settle_zk_escrow_is_a_pool_wide_pot_any_leaf_can_spend). A v1 pool must not hold value it cannot afford to lose. The fix (domain-separated leaves plus per-deposit accounting or a fixed denomination) is roadmapped and not in this branch.SettleZkenforces no k-floor, no denomination and no recipient freshness. Its anonymity set is the window's ZK deposits of the same amount, and the program will settle into a set of one. The floor is a client check (mirror-cli proverefuses belowk_floorunless waived) because the ZK escrow has no refund path and an on-chain floor would strand it. "Settles to a fresh address" is a client convention, not a program property.Reproduce