mirror-pool: behavioral anonymity set with a vault-signed uniform actor - #4
mirror-pool: behavioral anonymity set with a vault-signed uniform actor#4thomgabriel wants to merge 189 commits into
Conversation
- docs/research/prior-art.md: verified survey of Solana privacy protocols (Cloak, Light, Umbra, Elusiv/PrivacyCash/ORE, Arcium, Confidential Transfers) and the reusable production spine for a behavioral anonymity set. - docs/superpowers/specs/2026-07-15-mirror-pool-design.md: full-platform design (ZK shielded pool + relayer, generic PooledAction, k-floor, hardened custody, bonding incentives, opt-in disclosure) with threat model + testing strategy.
- plan: fold independent-review findings — generated program ID, Box the ~4KB Pool account + in-place field mutation (SBF stack), field-reference module APIs + on-demand zeros, absolute .so path + pool_program::ID shared test helpers, non-zero-seeded ring tests, unified compute-budget guidance, rent-exempt vault funding, mark_spent gating note. - spec: reconcile PDA seeds to ["vault", pool]; tree state embedded in Pool for SOL MVP. - research: add docs/research/cicd-and-testing.md (GitHub Actions, coverage w/ SBF caveat, e2e-after-every-change, security/supply-chain, phased rollout).
Comment discipline (why-not-what), YAGNI/no-overengineering, custody fail-closed rules, privacy invariants, TDD + LiteSVM testing, and the pure-fn-for-invariants rule that ties clean code to truthful coverage.
Anchor.toml, workspace Cargo.toml, and the pool-program crate with a placeholder ping instruction; declare_id! synced to the generated program keypair via `anchor keys sync`. Installed toolchain is anchor-cli 0.31.1 + Agave 3.0.1, ahead of the 2.1-era versions the brief assumed: - litesvm 0.6 / solana-sdk ~2.1 pinned as specified didn't resolve (crates.io has since drifted); bumped to litesvm =0.6.1 / solana-sdk ~2.2, the newest 2.x line still <3.0 per anchor-lang 0.31.1's own `solana-program = "2"` ceiling. - blake3 pinned to =1.8.3: solana-blake3-hasher pulls it unpinned, and 1.8.4+ needs digest 0.11 (edition2024), which the platform-tools bundled with Agave 3.0.1 (rustc/cargo 1.84) can't parse. - workspace.metadata.solana.tools-version = "v1.54" so cargo-build-sbf downloads a newer, edition2024-capable platform-tools instead of the v1.51 default. - lints.rust.unexpected_cfgs declares the cfg values anchor-lang's #[program]/#[derive(Accounts)] macros emit, so they don't trip rustc's check-cfg lint under -D warnings. Verified: anchor build produces target/deploy/pool_program.so, and a throwaway LiteSVM test (loaded the built .so and invoked ping) confirmed runtime compatibility before removing it — only tests/scaffold.rs ships.
cargo-build-sbf reads [package.metadata.solana].tools-version from the program manifest as authoritative; [workspace.metadata.solana] is only a fallback used when the package doesn't set one. Move the v1.54 pin to programs/pool-program/Cargo.toml so a fresh checkout resolves it deterministically instead of relying on the workspace-level fallback. Confirmed empirically that v1.54 is still required even with the blake3=1.8.3 pin: removing all tools-version config makes anchor build fail on indexmap v2.14.0's edition2024 requirement (an unrelated transitive dep, not just blake3's chain).
…with field checks anchor-lang 0.31.1 / solana-program 2.2.1 have no `poseidon` module (Agave split it into the standalone `solana-poseidon` crate); add it as a direct dependency and hash2 against that instead of the nonexistent anchor_lang::solana_program::poseidon re-export.
…ase 1 subsystem 2
… helpers Wires poseidon/merkle/roots into a real on-chain Pool PDA and adds the first LiteSVM integration test that runs the crypto inside the SBF VM. Pool had to become a zero_copy account (AccountLoader, not Box<Account<..>>): the brief's plain #[account] struct (~3.9 KB) blew the 4 KB SBF stack frame in Anchor's Borsh (de)serialization path, confirmed by both anchor build's linker warnings and an actual on-chain access violation. zero_copy avoids the stack copy entirely by reinterpreting the account's own backing bytes in place. initialize_pool consumes 31,594 CU on real SBF, well under the 400k budget.
Adds `deposit(commitment, amount)`: transfers lamports payer -> vault PDA, inserts the commitment into the Merkle tree, pushes the new root into the ring, and emits DepositEvent. Adapted the Deposit accounts to AccountLoader (Pool is zero_copy since Task 5) instead of Box<Account>.
Standalone mark_spent instruction; PDA existence at ["nullifier", pool, nullifier_hash] is the spent marker, so Anchor's init fails atomically on a re-spend. Deliberately ungated for now — must move inside withdraw, gated behind Groth16 verification, before any deployment.
…itesvm, cargo-deny) lint + host-unit jobs validated locally; build-test (SBF) and cargo-deny jobs flagged for first-run validation on GitHub Actions (see inline comments).
…-field test Zero-amount deposit test used a bare `.is_err()` (the same false-positive class hardened in nullifier.rs) — now asserts InstructionError::Custom(6001) (ZeroDeposit) plus the log message. Adds a new out-of-field-commitment deposit test asserting Custom(6002) (CommitmentNotInField), exercising the instruction-level require!(is_in_field(...)) wiring that pure-fn tests don't cover. Also corrects the cu_limit_ix comment, which still described Borsh (de)serialization headroom after Pool moved to zero_copy.
Pool derives Copy (it's bytemuck::Pod), so a stray *x.load()/by-value copy would silently reintroduce the ~4KB SBF-stack bug the load_init/load_mut RefMut-in-place pattern avoids. Adds a grep step to the lint job that fails on the deref-load pattern in program source; verified 0 matches against current src/ (legit ctx.accounts.pool.load_mut() has no leading `*`).
Fold review findings: install circom compiler + snarkjs (BLOCKER/HIGH); add Poseidon(1) nullifierHash parity + canonical Rust source (HIGH); fix incremental- tree fixture to last-inserted leaf w/ pre-insert path snapshot (HIGH); correct withdraw_js wasm path; add witness/input.json step; deterministic zkey beacon; big-endian-safe Fr conversion (Fr::from_be_bytes_mod_order, reverse+negate for groth16-solana); single note-bundle artifact; pin ark-circom 0.5 (no 0.6). Preserve review-verified-correct items in a dedicated section.
…ess tests Add Withdraw(depth) binding commitment = Poseidon(nullifier, secret), nullifierHash = Poseidon(nullifier), and Merkle membership of the commitment under the public root, exactly per the task-3 brief. Also make merkle_proof.circom include-only (no component main): circom disallows more than one `component main` across an include graph, and the file already declared one for Task 2's standalone parity test, which conflicted with withdraw.circom including it. Moved that main into a new thin merkle_proof_main.circom wrapper (same pattern as the existing poseidon1/poseidon2 wrappers) and repointed merkle_parity.test.js at it; no template logic changed.
Adds circuits/scripts/setup.sh (compile -> groth16 setup -> deterministic zkey beacon -> VK export), circuits/ptau/README.md documenting the public Hermez pot14 powers-of-tau fetch + sha256, and circuits/test/input.json (decimal field-element prover input derived from withdraw_vectors.json). Gitignore *.ptau so the ~18MB binary is never committed. Withdraw(20) compiles to 5313 constraints, comfortably under 2^14. Verified end-to-end: witness -> groth16 prove -> groth16 verify -> snarkJS: OK!, and confirmed the beacon-based setup is byte-identical across two independent runs (deterministic, not a live contribution).
…ment A same-power ptau swap silently produces a different, non-reproducible VK yet still verifies OK, since proof/verify consistency doesn't depend on ptau authenticity. setup.sh only checked the file existed; add a sha256 check against the value documented in ptau/README.md before running groth16 setup. Also reword a stale .gitignore comment claiming the test ptau "is committed explicitly where needed" — it's fetched and checksummed, never committed.
Real Groth16 proof for the withdraw circuit from the committed note bundle (circuits/test/withdraw_vectors.json), verified against circuits/build/verification_key.json via ark-groth16, with a tampered public nullifierHash rejected. Bonus: also verified against the exact groth16-solana on-chain byte format (proof.A negated + LE->BE, G2 c0/c1 EIP-197 swap), tamper-rejected there too. Big-endian <-> Fr conversion goes only through Fr::from_be_bytes_mod_order / into_bigint().to_bytes_be() (never ark's little-endian canonical (de)serialize) — asserted by a round-trip unit test. arkworks stays on 0.5 (ark-circom/ark-groth16/groth16-solana all confirmed 0.5-native; no version conflict). tests/prove_verify.rs runs circuits/scripts/setup.sh itself if circuits/build/* is missing, rather than silently skipping the real prove/verify.
ensure_build_artifacts() is called by both #[test] fns in this binary, which the default test harness runs concurrently. When circuits/build was missing, both threads independently detected that and raced to spawn circuits/scripts/setup.sh against the same output paths, so circom would clobber/NotFound and one test spuriously failed on a clean checkout or CI's first run. Guard the check-and-build with a static OnceLock<PathBuf> so only one thread ever runs setup.sh; the other blocks on get_or_init until it finishes and reuses the same build_dir. The loud panic on missing artifacts is unchanged.
…hdraw shielded pool
…d l-sigma cross-check simulation
…-SIM.md proof doc + captured run Fold Task-1 review finding E: simulate_disclosure now fails closed on m==n (no background destinations to compute the l-sigma threshold from).
….md (spec/plan/2 tasks/whole-branch all review-gated; every number reduces to a verified closed form, degradation-first)
…r structure-polish moved out of lib.rs is in-VM-only code llvm-cov cannot measure (same documented rationale; scoped set back to 97.07% >= 90 floor, verified locally with the exact CI command)
…re-polish/SOAK/adversarial-sim; drop stale 'not pushed' + 'two gaps'→one), add Future-work boundary section (deferred = deliberate scope w/ reasons), surface SOAK.md + ADVERSARIAL-SIM.md as the proof artifacts
…r,pool,C_m]; split swap-envelope cite (limits doc for the 64-lock wall, followup-proposal for the swap-specific load)
…eap wall), corroborated live by the SOAK' (the pin was the LiteSVM sweep; SOAK A7 is the live corroboration, not the pin)
…sh + Future-work boundary + proof-doc surfacing; docs-honesty review-gated)
…off-chain re-derivation checks, corrected non-vacuous k-gate (intent_count-1<k_floor) + self-sovereign override + additivity/local-log honesty
…ti-replay (transport confidentiality is the mitigation), F2 recompute nh from disclosed nullifier not payload field (closes victim-substitution), F3 Round.state==Executed check (committed≠performed), F4 Pool-level bindings denom/kind/validator, F5 underflow-safe k-gate, F6 cleartext-field-not-extDataHash, F7 root-ring retention, F8 gate-is-advisory, F9 verify against audited-pool not payload label, F10 compel-wording
…ABILITY never expires (append-only public tree; rebuild fresh path anytime), only a specific disclosure object ages out; build defaults to current-root path + assert Intent.action==Pool.action_kind too
…-free k-gate+override / verify 6-checks+ChainView+full tamper matrix); gate boundary tests + recompute-nh victim-substitution test + ChainView exactly the 6 checks' needs
…G is the check; commitment feeds check 2's Merkle walk to d.root, no phantom equality/poseidon_leaf_from)
…zed-k co-participant gate + self-sovereign override (SDK-local log)
…rom disclosed nullifier, audited-pool PDAs, Round.state==Executed, Pool bindings) + full fail-closed tamper matrix
…ty doc (session tag, NOT anti-replay) + drop never-constructed NoteNotInField variant
…rk into the Status table
…ale-vs-realized-anonymity, Rust-end-to-end), stale-doc fixes, CI node24 pins + stake_round in CI, rename stragglers
…lan/2 tasks/whole-branch review-gated; SDK-only, 13-case fail-closed tamper matrix, co-participant k-gate)
…CHITECTURE.md, specs to docs/design/, drop the plan scaffolding, add a docs/ index and a 'See it work' opener
…disclosed residuals) + mark ARCHITECTURE.md as the pre-implementation spec
…xecuted tests) · docs: scope the Rust-end-to-end claim to runtime (build/test-time JS exists)
…rop internal phase labels), the uniform-actor property stated as a wire-verifiable fact up top, engineering-rigor line, Future work moved below Build & test
…s removal, fix stale ext-data/effective-k/sdk crate descriptions, add cross-navigation pointers to every sub-README
…xplanatory sections after) · lead the min-entropy bullet with the decision instead of the citations · move the crowd-size-vs-realized-anonymity argument out of Limitations into Design rationale where it belongs
…claim positively, and upgrade the stake shape-channel disclosure to name the uniform fix and its k-cost
…gnored SBF + circuit artifacts) and the x86_64 proving block (wasmer/__rust_probestack, ARM-only), in README and SOAK reproduce steps
…andom recipient/relayer keys (Pubkey::new_unique collides with funded addresses on devnet) Deployed to public devnet and ran the withdraw round against it: k=17 settles in one execute_round with a single signer and 34 present-but-unsigned recipient/relayer keys. Program APAofkLeh6HD4UiQy1ttEn72kE95Te335KxUXDbj7Twm; declare_id moved to the keypair we hold. Local capture regenerated against the new id (14/14 assertions, both rounds).
…ner, 34 keys present-but-unsigned); soak gains --skip-stake/--keypair for public clusters
|
Thank you for this submission, and for the real cryptography in it. It didn't take the prize this round, but it was a strong, genuinely on-chain ZK entry and deserves a proper note. This is feedback for future submissions, not a line-by-line review. What you did wellYou built a real Groth16 membership pool with on-chain nullifier PDAs and a vault-signed uniform actor that is non-custodial: the vault can only pay the exact keys bound into each proof's Where it fell short of winningThe mirror-pool field was strong this round, and a few things separated the top entries. 1. Scope: WHO-onlyYou hide the initiator well, but not the amount, and your action set is fixed (withdraw / stake). The entries that placed ahead either added a confidential-amount layer or a broader action surface (an arbitrary vault-PDA CPI that lets the pool perform any member action, not a fixed menu). Widening either axis is the highest-value next step. 2. Measurement depthYour effective-k reporting is honest but lighter than the top entries', which measured their anonymity against real external on-chain fund flows and published the runs that failed. Turn your metric on real data, not just the design. 3. Trusted setupDev-only today, correctly disclosed. A distributable, reproducible multi-party ceremony is what moves it from testnet-grade to mainnet-credible. Thank youOn-chain ZK is hard and rare, and you shipped it working and verifiable. Thank you for the contribution; this is the kind of building that makes Solana more private. Please come back for the next one. |
What this is
A mixer hides how much moved and from whom. mirror-pool hides who initiated an action that everyone can see happened.
kparticipants pool one identical action (a withdrawal, a native-stake delegation) into a synchronized round. The round settles in one transaction signed by the pool's vault PDA, so no participant signature appears on any executed action. An observer seeskidentical actions occur and cannot attribute any of them. This is k-anonymity over actions, deliberately not over denominations: it is not a way to hide balances or move value privately.The coordination layer is in the program, not in a service. The
RoundPDA accumulates intents, thek-floor gates when a round may fire,execute_roundsettles it atomically, and a timeout-gatedcancel_intentis the escape hatch. There is no coordinator to trust, censor, or correlate against. Rust end to end at runtime: proving is native (ark-circom/ark-groth16), verification isgroth16-solanaon-chain.The property, read off the wire
Live on public devnet. Open the transaction and read the header:
execute_roundsettlingk = 17actions:5Wz42APhxBDk1Z1s…showsnumRequiredSignatures: 1. Seventeen recipients and seventeen relayers are present in that transaction, and none of them signed it. 3 static keys plus 56 ALT-loaded gives 59 resolved locks at 112,245 CU.APAofkLeh6HD4UiQy1ttEn72kE95Te335KxUXDbj7Twmdocs/soak-report-devnet.md(devnet, A1 to A7) anddocs/soak-report.md(local validator, both action types, A1 to A8)Every assertion is computed from a chain read and maps 1:1 to a named check in
crates/soak/src/assertions.rs: value conservation, byte-uniform payouts, single-spend with a duplicate-commit probe, round lifecycle, execution envelope, and the finalAuthorizedstate of each stake account.How well it hides, measured against ourselves
docs/ADVERSARIAL-SIM.mdleads with the regimes where mirror-pool degrades, before the one where it works:mofknotes collapses min-entropy effective-k from 17 to exactly 1.0 atm = k. The solo-operator soak above therefore reportseffective_k = 1, and the document states that at equal prominence with what passed.The metric is min-entropy (
k_∞ = 1/maxᵢ pᵢ, Smith FoSSaCS 2009) precisely because nominalkand Shannon entropy both look healthy while real anonymity sits at the floor.crates/effective-kis a measurement instrument, never an on-chain gate.What ships
On-chain: Poseidon accumulator, height-20 Merkle tree, 100-root ring, nullifier PDAs, Groth16 verification, the
k-floor, and aMAX_Kper-round cap pinned by measurement (17 withdraw, 10 stake, where the stake ceiling is a 32 KB SBF heap wall rather than a transaction-size one). TwoPooledActionadapters ship:Withdraw, and native-stakeStakein which the vault delegates unilaterally and hands the participant both stake authorities in the same transaction.Off-chain: a client SDK, a native Rust prover, and opt-in payment disclosure. A participant proves their own action to a verifier they choose via six off-chain re-derivation checks against published chain state, with a co-participant
k-gate that refuses by default when a disclosure would push the rest of the round below its floor. No global auditor, no protocol compel path, no backdoor. A 13-case fail-closed tamper matrix covers it.141 Rust tests plus a circom to on-chain byte-parity suite. CI enforces
fmt,clippy -D warnings, a scoped coverage floor,cargo-deny, and a verifying-key drift guard on every push.Where the guarantee stops
docs/THREAT_MODEL.mdargues why and discloses the trade rather than inflating the number.k. Bonding was deliberately not built, because our own research concluded a bond is a price rather than a proof.MAX_K_STAKE. The reasoning is in the threat model.Reproduce
Proof generation needs ARM (aarch64), because
ark-circomlinkswasmer, which does not link on x86_64; everything else builds and tests normally there. Details, and a map of the documentation, are indocs/README.md.MIT. Research-stage, unaudited.