Skip to content

mirror-pool: behavioral anonymity set with a vault-signed uniform actor - #4

Open
thomgabriel wants to merge 189 commits into
solanabr:mainfrom
thomgabriel:main
Open

mirror-pool: behavioral anonymity set with a vault-signed uniform actor#4
thomgabriel wants to merge 189 commits into
solanabr:mainfrom
thomgabriel:main

Conversation

@thomgabriel

Copy link
Copy Markdown

What this is

A mixer hides how much moved and from whom. mirror-pool hides who initiated an action that everyone can see happened.

k participants 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 sees k identical 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 Round PDA accumulates intents, the k-floor gates when a round may fire, execute_round settles it atomically, and a timeout-gated cancel_intent is 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 is groth16-solana on-chain.

The property, read off the wire

Live on public devnet. Open the transaction and read the header:

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 final Authorized state of each stake account.

How well it hides, measured against ourselves

docs/ADVERSARIAL-SIM.md leads with the regimes where mirror-pool degrades, before the one where it works:

  • Whale self-fill. One funder owning m of k notes collapses min-entropy effective-k from 17 to exactly 1.0 at m = k. The solo-operator soak above therefore reports effective_k = 1, and the document states that at equal prominence with what passed.
  • Repeated participation. The Danezis 2003 statistical-disclosure closed form, implemented and allowed to win: it reports how many rounds until a repeat participant is deanonymized, per action type.

The metric is min-entropy (k_∞ = 1/maxᵢ pᵢ, Smith FoSSaCS 2009) precisely because nominal k and Shannon entropy both look healthy while real anonymity sits at the floor. crates/effective-k is 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 a MAX_K per-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). Two PooledAction adapters ship: Withdraw, and native-stake Stake in 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

  • The trusted setup is dev-only (deterministic beacon, public toxic waste), so a setup-secret holder could forge proofs. Do not run this with real value.
  • The per-round set is small (17 or 10) and anonymity is per round, not cumulative. We trade set size for the strongest available synchronization; docs/THREAT_MODEL.md argues why and discloses the trade rather than inflating the number.
  • Sybil resistance is priced, not solved. A mandatory uniform fee raises the nominal cost of self-fill without deepening distinct-human k. Bonding was deliberately not built, because our own research concluded a bond is a price rather than a proof.
  • One open shape channel on the stake path, with a uniform fix we costed and declined because it would roughly halve MAX_K_STAKE. The reasoning is in the threat model.
  • The stake round is exercised on a local validator rather than devnet, since each intent needs the network-minimum 1 SOL delegation, beyond what a public faucet provides. Devnet is a public cluster, not mainnet.

Reproduce

bash circuits/scripts/setup.sh                                        # circuit artifacts
cargo build-sbf --manifest-path programs/pool-program/Cargo.toml      # SBF program
cargo test --workspace

cargo run -p soak                                                     # live run -> docs/soak-report.md
cargo run -p effective-k --example adversarial_sim                    # degradation tables

Proof generation needs ARM (aarch64), because ark-circom links wasmer, which does not link on x86_64; everything else builds and tests normally there. Details, and a map of the documentation, are in docs/README.md.

MIT. Research-stage, unaudited.

- 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.
… 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.
…-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
…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
@kauenet

kauenet commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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 well

You 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 extDataHash, so no one can deanonymize by trusting it. Your ZK path is live and independently verifiable on devnet; we confirmed the k=17 execute_round transaction (err:None, one required signature, 34 recipient/relayer keys present-but-unsigned, ~112k CU), which lands because your verifying key is embedded in the deployed bytecode. Your scope disclosure is honest throughout ("Sybil resistance is priced, not solved"; dev-only trusted setup).

Where it fell short of winning

The mirror-pool field was strong this round, and a few things separated the top entries.

1. Scope: WHO-only

You 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 depth

Your 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 setup

Dev-only today, correctly disclosed. A distributable, reproducible multi-party ceremony is what moves it from testnet-grade to mainnet-credible.

Thank you

On-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.

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.

2 participants