Skip to content

mirror-pool: two-path behavioral anonymity pool (synchronized crowd + ZK-deniable initiation) - #1

Open
marcelofeitoza wants to merge 67 commits into
solanabr:mainfrom
marcelofeitoza:feat/mirror-pool-v1
Open

mirror-pool: two-path behavioral anonymity pool (synchronized crowd + ZK-deniable initiation)#1
marcelofeitoza wants to merge 67 commits into
solanabr:mainfrom
marcelofeitoza:feat/mirror-pool-v1

Conversation

@marcelofeitoza

@marcelofeitoza marcelofeitoza commented Jul 17, 2026

Copy link
Copy Markdown

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:

  • Crowd path (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.
  • ZK opt-in path (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.
  • Confidential-value layer (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

  • Program on public devnet: EezWdFrmHtR2PCuucUruvkgyB9HW3w2KskZNeYmXszBq
  • Live soaks, every assertion on-chain: behavioral 17/17, confidential 25/25, funding-round 25/25, with signatures and CU in docs/PROOF.md
  • Membership settle: 102,115 CU, 4 public inputs (the JoinSplit is a separate instruction with its own verifying key at ~197k CU, 7 inputs)
  • 262 host tests, 99 on-chain program tests, design paper at paper/mirror-pool.pdf

The deployed program predates the verifying-key registry, and reproducing the ZK,
confidential and funding soaks on a public cluster is currently blocked on funding.

All three of those drivers publish their key through InitVk before they settle, which
the deployed bytecode does not have. The current build is 119,072 bytes and needs about
0.83 SOL of rent to deploy or stage, and the devnet faucet is refusing this address.
The captured results are unchanged and are labelled historical in docs/PROOF.md; the
crowd path is byte-identical source between the deployed commit and HEAD, so those
assertions carry over. Until a redeploy is funded, treat the ZK, confidential and
funding evidence as local Surfpool evidence.

What is here

  • Multi-party trusted-setup ceremony (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 under docs/ceremony-run/.
  • Opt-in association-set proofs (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.
  • Funding-provenance mechanism, wired and soaked - commit wallets are funded by an unshield out of the value pool in denominated, batched rounds. Proven live: each fresh commit wallet's only inbound transfer is the pool vault, and each funding transaction carries exactly one signature, the relay's.
  • Write-once, digest-pinned verifying-key registry - each circuit's key lives in a program-owned PDA at seeds = ["vk", circuit_id], installed by a single InitVk instruction 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.
  • Arkworks-native membership circuit (docs/ARKWORKS.md) - an additional Rust-only constraint-synthesis path with an in-circuit Poseidon gadget, cross-checked against the native hash and the sol_poseidon syscall. It does not replace the circom path and does not change any program logic. Honest scope: it verifies through the groth16-solana verifier crate in a host test, not as a landed SettleZk, because a different constraint system means a different key and the program pins the circom key's digest.
  • Effective-k measured, not assumed (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:

funding approach effective-k retention
public funding edge (baseline) 7.51 23.5%
pass value through the shield only 7.66 23.9%
fixed denomination only 10.19 31.8%
batched rounds only 10.93 34.2%
denomination + rounds, enforced rules alone 24.25 75.8%
denomination + rounds + two rounds of voluntary dwell 28.80 90.0%

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

  • The ZK escrow is a pool-wide pot (soundness). No on-chain check ties a settled amount to any single deposit, and because crowd Commit and CommitDeposit produce 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.
  • SettleZk enforces 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 prove refuses below k_floor unless 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.
  • No production ceremony has been run. The machinery exists and is tested, but the committed and deployed verifying keys still come from the insecure dev setup. The local demonstration reports 1 independent contributor from 4 steps, which is the self-run refusal working.
  • Effective-k is a model over synthetic populations; the funding trace is modelled, not read off a chain, and the Sinkhorn step is an approximation rather than a bound (a counterexample is pinned in a test).
  • Public devnet, not mainnet-beta. Anti-Sybil is economic, not cryptographic. Research-stage; not audited.

Reproduce

cargo test --workspace
cargo build-sbf --manifest-path programs/mirror-pool/Cargo.toml
cargo run -p mirror-harness --release      # attack table + effective-k tables

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
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant