Skip to content

An anonymity set for protocol actions, with the funding-provenance channel measured - #5

Open
thiagorochatr wants to merge 83 commits into
solanabr:mainfrom
thiagorochatr:feat/provenance-aware-pool
Open

An anonymity set for protocol actions, with the funding-provenance channel measured#5
thiagorochatr wants to merge 83 commits into
solanabr:mainfrom
thiagorochatr:feat/provenance-aware-pool

Conversation

@thiagorochatr

Copy link
Copy Markdown

A pool that gives an anonymity set to protocol actions rather than to funds.
Members deposit a fixed denomination. Later a member proves in zero knowledge
that they own some note in the set and directs the pool to perform an arbitrary
instruction on an arbitrary program. The pool performs it, signs it, and settles
it batched with everyone else's under one signature at one timestamp.

An observer sees that a stake, a swap or a vote happened, and cannot say which
member asked for it.


What is in this PR

on-chain program four instructions, no Anchor, Groth16 verified by the chain's own syscall at ~101,000 CU
circuit arkworks R1CS, written in Rust — no Circom, no snarkjs, no JS anywhere
measurement crate funding-provenance analysis over real mainnet data, samples committed
CLI the member's tool and the operator's, 18 subcommands
tests 261, green from a clean clone
documentation 11 documents and a 39-page paper
devnet the whole lifecycle, with every signature published

The two claims, and only these two

1. The action side is closed. Actions execute from the pool's vault PDA, so
an action's on-chain trace leads to the pool and is identical for every member.
No member key appears on chain at any point after the deposit — spends are
relay-signed, settlements are settler-signed.

2. The membership side is measured. Any deposit-based anonymity set on a
public ledger can be partitioned by where each member's capital came from. No
pool controls that and no circuit closes it. So it is measured, against live
mainnet data
, with the method published beside the number:

ρ = 0.0955   unresolved bracket 0.0350 … 0.1136
             95% sampling interval 0.0848 … 0.1790

54 of 83 members resolved, zero infrastructure failures. Knowing a member's
funding class costs that pool roughly an order of magnitude of its nominal
anonymity.

The pool measured is not this one. It is a live, unrelated Solana mixer,
because mirror-pool has no depositors and measuring our own empty pool would be
measuring nothing.

Things this repository does that are worth a look

Every number here is taken, not modelled. No simulated distribution appears
anywhere in the measurement path. Where a figure is derived rather than landed —
the delegation ceiling through a lookup table is the only case — the document
that publishes it says so in those words.

Six of the eight collection runs produced no publishable figure, and all
eight are published: one that resolved nothing, one refused by the failure gate,
three refused by the informativeness gate, and one invalidated by a defect we
found in our own tracer. The gate that refuses this work's own favourable
cross-population comparison was added after that result was known to be
favourable.

The metric is turned on this project too. docs/CROWD.md runs it against our
own devnet crowd, where it returns the best value it can produce — and then
explains why that number is worthless: every note in that pool was funded by one
wallet, so the partition has one class and the class is us.

The trusted setup is reproducible and you can check it in one command. The
seed is committed, the key is derived from it rather than withheld, and the check
binds the whole verifying key rather than delta alone:

$ mirror verify-setup --expect b0165d5eac6fe8273b6564c78e8ba548c97e6050ae785e9142de63c81aa905b7
MATCH — every element of the program's verifying key is reproduced by this seed

Crowd ceilings were measured, not estimated, for each shape of action:

the batch legacy through a lookup table
plain payments 10 20 on devnet, 64 locks, one signature
delegations, one validator 7 18
delegations, a validator each 6 13

Divergent behaviour costs anonymity-set size, and that trade is priced rather
than hidden.

The paper

A Behavioural Anonymity Set for Solana
— 39 pages, 26 references, 5 figures, 7 tables. It argues both halves as one
claim, positions the work against the mixing and anonymity-metric literature, and
carries the full method and all eight runs. Every figure in it is traceable to a
test or a transaction in this repository.

What this does not claim

Stated in full in docs/THREAT_MODEL.md, and the short
version:

  • Not on mainnet, deliberately. The trusted setup is reproducible, not
    secure — the seed is public, so proofs are forgeable. A live pool on those
    terms would invite deposits it cannot protect.
  • The funding-provenance channel is not closed. It is reduced on the action
    side and measured on the membership side.
  • The proof binds how many accounts an action takes, not which. For a target
    whose destination lives in an account slot, the settler chooses — so the crowd
    run reads every stake account back off the cluster rather than asserting it.
  • Incentives are 4 of 5. Four are enforced by the program; the reward for
    dwell was designed and cut, because a reward must be paid to somebody and
    naming a member's address is the one thing this design spends everything else
    avoiding. The anonymity-preserving version is specified in
    docs/INCENTIVES.md rather than gestured at.
  • Not audited, and the program is upgradeable.

Running it

make verify     # fmt, clippy -D warnings, build-sbf, 261 tests

Two prerequisites, both named in the README: rustup and the Agave toolchain.
Nothing needs a network, a key, or an account with anybody. It is the same
command CI runs, so the two cannot drift apart.

Live on devnet at
8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa.

Rust workspace for the mirror-pool contribution. Pins the arkworks major to
0.5 across every crate: light-poseidon 0.4 is built against 0.5, and Fr from
0.5 and 0.6 are distinct types that do not unify.

Validated: groth16-solana + light-poseidon + ark-bn254 build for SBF under
Agave 4.1.1 / platform-tools v1.54.
The groth16-solana integration notes were verified by execution against the
pinned versions rather than taken from documentation, which is stale: it shows
an API removed in 0.0.3 and arkworks 0.3 traits that no longer exist. Records
the G2 c1/c0 cross order, the mandatory proof_a negation, and the measured CU
curve (74,179 + 5,661 per public input) that drives the 3-input circuit design.

Also corrects ark-bn254 to features=["curve"]; scalar_field alone yields Fr
but not the curve groups.
…lator

Poseidon goes through solana-poseidon only, which is cfg-gated upstream to the
syscall on-chain and light-poseidon off-chain, so host and program compute one
function by construction. poseidon2(1,2) is pinned against circomlib's published
vector, so the gadget added next is checked against an external constant rather
than against our own implementation.

Domain separation is by arity rather than by an integer tag. The tag design was
tried first and was wrong: a Merkle node whose left child equals a small tag
constant collides with a nullifier.

Notes bind their denomination, and nullifiers are spend-once rather than
epoch-scoped, which makes both of the drain bugs found in competing submissions
unrepresentable rather than merely untested.

Frontier (on-chain view) and MerkleTree (host view) are asserted to agree over
real insertion sequences, and every host path is folded against the accumulator
root. 23 tests, clippy clean under -D warnings.
The gadget mirrors light-poseidon's permutation and reads its published round
constants and MDS matrix rather than re-deriving them, so the constants have one
source and the gadget cannot drift from the syscall.

Verified two ways: it agrees with the native hash over random inputs at arities
1 through 4, and it reproduces circomlib's published poseidon([1,2]) vector,
which mirror-core also pins. Gadget, host and syscall are therefore each checked
against an external constant rather than against each other.

Rejected ark-crypto-primitives' PoseidonSponge: its squeeze does not agree with
circomlib's fixed-arity compression, and the one open Solana project that made
it primary later migrated off it. Several published projects ship a gadget whose
native and in-circuit hashes are different functions; the parity test here is
what catches that.

Arity-2 permutation costs 243 constraints, so a depth-20 path is under 5k.
Three public inputs — root, nullifier, action_binding — because on-chain
verification measures at 74,179 + 5,661 per input, so each one costs ~5.7k CU.
denom_tag stays a witness since Merkle membership already constrains it.

The action binding is squared under constraint deliberately: a Groth16 public
input participates in verification only if some constraint references it, and an
unreferenced input can be dropped by the optimiser, leaving a relay free to swap
the action after the fact and still verify.

Setup is deterministic from a committed public seed. That trades ceremony
secrecy for reproducibility in the open, and it is not a secure setup — the
seed is public, so proofs are forgeable, and the multi-party path is documented
separately. The competing approaches fail differently: one publishes its entropy
string while gitignoring its proving key, so no third party can produce a valid
proof for the deployed program at all.

tests/onchain_layout.rs closes the loop against the real groth16-solana verifier
rather than a stand-in, including a regression test that a non-negated proof_a
is rejected. 16 tests.
…ucibility claim

Audit of the work so far turned up three defects of exactly the kind this
submission criticises in others:

- hash.rs documented an arity-4 action binding that did not exist, and poseidon4
  was exported but never called. The binding is a real requirement — the program
  recomputes it from the action it executes — so it now lives in mirror-core,
  shared by host and program so the two cannot drift. A 32-byte pubkey exceeds
  the BN254 scalar field, so it is split into two 16-byte halves rather than
  reduced; reducing would let two beneficiaries share one binding.

- The circuit computed the binding's square twice, spending three constraints
  where one suffices. Reduced to one, and the integration test that tampers with
  that public input still rejects, which is what proves the input is genuinely
  bound rather than merely present.

- The reproducible-setup docstring overclaimed. StdRng is not guaranteed
  portable across rand releases and arkworks' randomness consumption order is an
  implementation detail, so reproducibility is scoped to the committed lockfile.
  Said so.
The on-chain deposit path needs an empty-subtree root at every level it climbs,
and deriving the ladder costs roughly 17k compute units. Baking it as a constant
makes that free.

A hardcoded ladder that disagrees with the hash function is a nasty failure:
deposits keep succeeding while no proof can ever verify, because every root is
computed against empty subtrees that no longer exist. So the derivation stays as
the reference implementation and a test asserts the two agree level by level.
A second test asserts every baked entry is a canonical field element, since a
non-canonical constant would only fail at runtime, on-chain, with no diagnostic.
Explicit fixed offsets rather than a serialisation framework: the layout is the
on-chain ABI and is worth reading in one place, every accessor is bounds-checked,
and the crate forbids unsafe so nothing transmutes account data. Compile-time
asserts pin the offsets, since a field inserted in the middle would silently
reinterpret every deployed account.

required_vault_lamports is the protocol's accounting invariant. Because the
denomination is a pool constant rather than a hidden field, the amount owed is a
function of two counters and nothing a prover supplies can influence it. The
counter refuses a spend that would exceed deposits outright, so the drain shape
that breaks both competing implementations is rejected before any lamport moves
rather than caught downstream.

Root history is 128 entries. A competitor keeps 32 and appends two leaves per
spend, so proofs there age out after roughly sixteen operations.

Also fixes two build defects found while verifying:

- cargo build-sbf fed a host .dylib for a proc-macro to the SBF rustc when it
  shared a target directory with the host build. The SBF build now gets its own,
  and the program stays a workspace member so cargo test --workspace still covers
  it — excluding it is how a competing submission ended up never running any of
  its on-chain tests in CI.
- That directory must be an absolute path. cargo resolves a relative
  CARGO_TARGET_DIR against the manifest directory, so the build landed under
  programs/mirror-pool/ while post-processing looked at the workspace root, and
  the build reported success while producing no .so at all.
Every variant parses at an exact length; a short buffer is not zero-extended and
a trailing byte is not tolerated. every_wrong_length_is_refused walks every
prefix of every encoding plus an overlong one, so 'fails closed on shape' is a
checked property rather than a README claim.
…erted

One pool per denomination, globally, and creation is permissionless. That is a
privacy decision rather than a convenience one: splitting deposits of the same
size across pools splits the anonymity set, and a split set is worse for every
member in it. There is also no privileged authority, so no key whose loss
freezes the escrow.

The deposit amount is read from the pool, never from the instruction, so a
deposit cannot claim a size the pool did not set — that is the half of the
accounting invariant a competing implementation lacks, where escrowed lamports
are a caller-supplied parameter never bound to the hidden commitment. The
invariant is then re-read from the vault after the transfers land rather than
assumed to hold.

Escrow lives in its own vault PDA with no data, so the invariant reads against a
balance holding nothing but escrow and rent; entry fees accrue on the pool
account instead, where they can never be mistaken for lamports backing a note.

the_program_insert_matches_the_host_accumulator walks 17 deposits and asserts
the program's on-chain frontier and the host's produce identical indices and
identical roots at every step. A divergence there means every proof the host
builds targets a root the chain will never hold.

Duplicate commitments are deliberately not rejected, with the reasoning
recorded: it would cost a marker account per deposit, and a duplicate only burns
the money of whoever submitted it.
The nullifier marker and the pending action share one account, seeded by the
nullifier. Its existence is the replay guard — creation fails if it already
exists, so a replayed proof cannot produce a second record however valid it is —
and its contents are the action the proof authorised.

The action binding is never transmitted. It is recomputed on-chain from the
selector, the beneficiary and the relay fee, then used as the third public
input, so a relay that alters any of them produces a different binding and the
pairing simply fails. There is no separate field that could be checked
incorrectly or forgotten.

Nothing is paid out here. Settlement executes the batch so every action in an
epoch lands on one timestamp and one ordering, which is what a synchronised
crowd means; a per-spend payout would leak the timing the pool exists to hide.
The spend counter is deliberately not advanced yet either — the note is
committed to be paid but has not been paid, so required_vault_lamports stays an
upper bound on what is owed until settlement disburses.

The relay signs, never the member: a member paying their own fee would sign with
their own wallet and destroy their own anonymity. Relaying is permissionless.

The k floor bounds program-visible membership only. The comment says so, because
the effective anonymity set is smaller once an observer partitions members by
funding provenance, and that is measured off-chain rather than asserted away
here.

Also adds the reproducible "mirror setup" and "mirror verify-setup" commands and
the generated verifying key (3 public inputs, sha256
b0165d5eac6fe8273b6564c78e8ba548c97e6050ae785e9142de63c81aa905b7).
verify-setup binds the whole key — alpha, beta, gamma, delta and every IC
point — so a transcript carrying a key for a different circuit cannot pass; a
competing ceremony verifier checks delta alone and would certify one.
Loads the .so that make build-sbf produces into a real SVM, sends real
transactions, and verifies a real Groth16 proof through the actual syscall. Every
other test in this repository calls functions directly; this is the only one that
can catch a divergence between what the host believes and what the chain does.

submit_spend costs 111,029 compute units, inside the 200k default budget with
room to spare. Each rejection carries its own code, and the cheap checks run
first: below the anonymity floor fails at 1,876 CU and an unknown root at 3,099,
both before the pairing is ever attempted.

Two real defects this surfaced:

- The replay test passed for the wrong reason. Resubmitting through the same
  relay produced a byte-identical transaction, so the runtime rejected it as
  AlreadyProcessed and the nullifier guard was never reached. The replay now goes
  through a different relay, and the test asserts the failure is *not*
  AlreadyProcessed so it cannot silently regress to that.

- With the guard genuinely reached, the error was Custom(0) — the system
  program's "already in use" — because a failed CPI terminates the instruction
  before the caller's map_err runs. The guard is now an explicit check ahead of
  account creation, so a replay reports NullifierAlreadySpent. That matters
  because devnet evidence records error codes as proof of negative cases, and a
  bare system-program error proves nothing about this program.

Getting here also required aligning the dependency graph: the Solana crates are
mid-migration on wincode, and solana-address 2.7 implements against 0.6 while
solana-instruction 3.4, the newest published, still derives against 0.5. Eight
crates are pinned to the 0.5 line so exactly one version exists in the lockfile.
Executes a batch of pending spends in one transaction, so every payout shares a
timestamp and an ordering and an observer watching beneficiaries receive funds
cannot use arrival time to tell them apart. 9,402 compute units for four spends,
because the expensive work already happened at submit.

The crowd rule is conditional: a batch needs k_floor spends, or every spend in it
must have waited out an hour. Requiring the crowd unconditionally would be a
liveness hazard — a quiet pool could hold a member's funds until a crowd that
never comes — and dropping it would make "synchronised" a word rather than a
property. Settlement is permissionless, so a member can always settle their own
batch once the timeout passes. Escrow is never hostage to anyone's liveness.

The spend record now carries its pool and its submission time, so a record from
one denomination cannot be presented to another pool's vault, and the timeout is
checked against a value the program wrote rather than one a caller supplies.

Vault lamports move by direct mutation rather than a system CPI, since the
program owns the vault. No signer seeds and no nested invoke on the hot path.

Negative cases, all with distinct codes: settling one pending spend twice inside
a single batch (the double-spend an attacker reaches for first), replaying a
settled batch, settling below the crowd size before the timeout, and paying a
beneficiary the proof never bound.

The harness now treats runtime deduplication as a test bug rather than a pass.
Two settlement tests were failing this way — a byte-identical retry is rejected
as AlreadyProcessed before the program runs, so the assertion proved nothing.
`send` panics on it with an explanation instead, which closes the whole class
rather than the two instances I happened to notice.
…ction

A member who cannot get a relay to act signs as their own relay with a zero fee,
and settlement is already permissionless, so they settle their own batch once the
timeout passes. Nothing in the protocol can hold their escrow: no relay has to
cooperate and no operator has to be alive.

So there is no self_spend instruction. The property was already there; what was
missing was proof of it. Adding an instruction would have been more code for a
guarantee the protocol already made.

The cost is the expected one — their own wallet signs, giving up the anonymity
the relay path provides. It is an escape hatch rather than a mode of operation,
and the test says so.
Ten quantities rather than a scalar, because a single number averages away the
tail and the tail is where the members with no anonymity are.

The folklore formula is inverted and this module says so. 2^H(C) — entropy over
the class-size distribution — is widely quoted as the effective anonymity set,
but it is the leakage: it is maximised when every member stands alone, which is
total deanonymisation. all_singletons_is_total_deanonymisation pins the contrast,
computing both and showing the folklore quantity reporting "32" for a set of 32
people who each have none.

The headline is the loss factor rho = 2^-H(C) rather than effective-k, because it
is independent of k and therefore comparable across pools. Effective-k measured
at small k systematically understates the steady-state loss and cannot be
extrapolated upward, so a headline in those units is not portable.

worst_case_is_informative encodes something the field gets wrong. Under any
heavy-tailed provenance prior somebody is always alone — P(min = 1) is about 1.00
for every k up to 512 — so "worst case is 1" describes provenance in general, not
the pool being measured. A published measurement of a live Solana pool reports it
as a finding; here it is only informative when the smallest class exceeds one.

Correct attribution, since the usual citation is wrong: H is Serjantov-Danezis
(PET 2002, Def. 2), defined in bits over users. The exponentiated form as a
member count is Andersson & Lundin (2008, Def. 1), who dispute the
misattribution.

Both reference vectors from the methodology are asserted to 1e-9, and the chain
rule H(C) + H(X|C) = log2 K and the ordering invariant are property-tested, so an
arithmetic slip cannot pass quietly.
…d RPC client

Three pieces of the collector, none of which touch the network to be tested.

Edges come from balance deltas, not instruction parsing. Parsing system
transfers misses every program that moves lamports by direct account mutation
and emits no transfer instruction at all, plus createAccount, transferWithSeed,
withdrawNonceAccount and closeAccount returns. Deltas see value movement however
it was caused. The payer's fee is added back so paying for a transaction is not
mistaken for sending value, misaligned balance arrays are refused rather than
zipped short, and a credit with several debited accounts is marked ambiguous
rather than attributed to a guess.

The failure taxonomy exists so an infrastructure limit can never read as an
absence of evidence. RPC failures are counted on their own line and excluded
from the class distribution entirely; above a 1% failure rate the run refuses to
print a headline rather than printing a warning above one, because a warning gets
dropped when the number is quoted. A published tracer in this space uses
unwrap_or(0) for signature counts, so a throttled call there makes an address
look like it has no history — which inflates precisely the bucket that flatters
the measurement.

The RPC client has no unwrap_or anywhere, and it checks the endpoint before the
run. Verified live both ways: the public mainnet endpoint reports first available
block 0 and serves slot 50,000,000 from 2020, so it passes; a third-party
"public" endpoint reports a first available block 434,542,394 and is refused with
exit code 2. That one returns null rather than an error for pruned history, so
without this check a collection against it would look healthy and report every
old funding event as absent.
Collection and classification are separate passes, and that split is the point.
Two of the five rules — cluster membership and fan-out — are properties of the
whole sample rather than of an address, so classifying during traversal would
make a member's class depend on the order it happened to be visited in.
Collecting first and classifying over the complete set means the same sample
always yields the same partition, which is what makes a published result
checkable. classification_is_independent_of_collection_order pins it.

The rules run R1, R2, R3, R5, R4 — R4 last, rather than fourth as the
methodology table lists it, and for a stated reason. R4 is the only rule that
names nothing: it yields busy-unlabelled, which is explicitly not an
attributable origin. Any rule that can name an entity therefore gets the chance
first, and R4 is the residual for "busy, and we could not say what". Evaluating
it before R5 would relabel a demonstrable distributor as an anonymous hub and
discard information already paid for in RPC calls.

R1 is the rule no prior submission in this bounty performs, and it is one call:
an account not owned by the system program is a program or a PDA, which is what
separates "funded by a protocol vault" from "funded by a person".

The hub threshold is decoupled from the paging cap, and a test asserts the gap.
The competing tracer sets its threshold equal to the RPC page limit, so "is a
hub" there means "hit the page cap" — which admits every DEX program and bot
while excluding a genuine exchange withdrawal address with 800 transactions.

Cluster ids are the lexicographically smallest member rather than the first one
seen, so overlapping fee-payer groups resolve the same way every run.

One real bug caught by its own test: age used saturating_sub on i64, which
saturates at i64::MIN rather than at zero, so a first-seen timestamp in the
future — clock skew or bad data — produced a large negative age that would have
passed nothing and failed silently.
…/analyze CLI

Pass one walks each member's funding chain and writes everything it observed to a
sample file; pass two classifies that file offline. The sample is the artifact
the methodology says to commit, so collection and reproducibility come from one
mechanism rather than two: anyone holding it recomputes the headline without RPC
access and without trusting that our endpoint behaved the same way on their
machine.

Frame validation drops seeds that are not wallets — programs, PDAs, token
accounts, addresses whose account no longer exists. That is a definitional
criterion and the excluded count is published, because excluding addresses for
looking hard to trace would be a different thing entirely and is exactly the bias
that inflates a competing measurement's untraceable bucket.

Two defects found by running it against live mainnet rather than by reasoning
about it.

The first was mine and it was silent. Frame validation fetches each seed's owner
before tracing, and the observation cache treated "we know the owner" as "we have
observed this address", so the trace skipped signature paging entirely and never
looked for a birth edge. Every member came back unresolved. It was visible only
because the run reported 14 RPC calls for 12 seeds and the census reported zero
resolved — a collector that defaulted a skipped lookup to "no history" would have
produced a plausible headline with nothing to indicate anything was wrong.

The second is a finding rather than a bug, and it is recorded in
docs/MEASUREMENT_LOG.md. Sampling addresses because they appear credited in a
recent block is transaction-weighted, not member-weighted: an address that
transacts a thousand times a day is a thousand times likelier to appear in any
block, so the frame is size-biased toward precisely the addresses whose funding
history is most expensive to walk. Eleven of twelve seeds turned out to be
market makers and bots at the 20,000-signature page cap. The right frame for a
pool is its depositors, each counted once.

The tool printed no headline from that run. Reporting nothing was the correct
output, and the measurement log records the run anyway — a measurement project
that only keeps its successful runs is selecting rather than reporting.

Also adds docs/ARCHITECTURE.md.
…positors

The frame decides what a measurement is of, and getting it wrong is the easiest
way to produce a confident number about the wrong population. Our own first run
did exactly that, so frame construction is now a command rather than a script:
auditable, testable, and recorded in the manifest.

Each depositor appears once however often they transact. Sampling addresses
because they appear in recent blocks is size-biased — an address transacting a
thousand times a day is a thousand times likelier to be drawn — which is how a
frame fills with market makers instead of members.

The sample is spread across the pool's whole signature history by walking pages
backwards and striding within each page, rather than taken from its most recent
minute. A published measurement in this space draws its entire sample from nine
consecutive slots, roughly four seconds of chain time, and reports Wilson
intervals over it as though it were a population sample; the run here reports the
slot span it actually covered so a reader can judge that for themselves.

Nothing is excluded for being hard to trace. Busy depositors stay in, because
dropping them would inflate the resolved fraction by construction, and that is
the bias that makes a competing measurement's untraceable bucket look like a
finding about the pool rather than about its sampler.

Withdrawals are distinguished from deposits by requiring the fee payer to have
parted with value beyond the fee and something else in the transaction to have
gained, so a relayer moving nothing of their own never enters the frame as a
member.
Leads with the problem every serious submission in this bounty identifies and
none closes — that an anonymity set on a public ledger can be partitioned by
where each member's capital came from — and states the two things this
submission claims about it rather than implying a third.

Includes a section of what is deliberately not claimed, because in a bounty that
asks for measured rather than asserted, the boundary is part of the result.
…n a member's behalf

The brief asks for "Tornado Cash for behavioural patterns and withdrawals — not
for funds". What existed answered the wrong question: deposit a fixed
denomination, withdraw a fixed denomination, which is a value mixer. What should
be deniable is that *you* staked, swapped or voted, not merely where your money
went.

Settlement now dispatches on a selector. Selector 0 stays the plain transfer.
Selector 1 invokes an arbitrary target program with a payload the member fixed at
submit time, signed by the vault PDA — so from the chain's point of view every
member's action was taken by the pool, and the on-chain trace of a stake made
through it is identical whoever asked.

a_crowd_of_members_perform_a_real_protocol_action_together settles four such
actions in one transaction at 39,584 CU, against the real SPL Memo program
fetched from mainnet rather than a stub. An observer sees four identical actions
land at one timestamp under one signer.

The action binding moved from Poseidon to keccak, because a payload is variable
length and Poseidon is a fixed-arity compression. It now covers the selector, the
target program, the beneficiary, the relay fee and the payload — every field a
relay could alter — and the tests confirm that swapping the target or editing the
payload makes the pairing fail. The circuit never computes this value, it takes
it as an opaque public input, so the change costs no constraints.

The payload lives in the spend record rather than the settle instruction. That is
what keeps the crowd synchronised: the instruction data is already on chain, so a
settle transaction carries only account references and several actions still fit
in one transaction. It is bounded at 256 bytes for the same reason — an unbounded
payload would trade the synchronised crowd for expressiveness.

Self-invocation is refused explicitly. A payload targeting mirror-pool would
re-enter settlement in the middle of a lamport-moving loop, and re-entrancy is
not a property to leave to careful reading of the loop.

The target program's own account must be supplied at settlement and is checked
against the record, so a settler naming a different program is refused before any
value moves rather than discovered by the runtime.

One defect the tests caught: solana-keccak-hasher's off-chain implementation sits
behind a sha3 feature, so without it the host build compiles and then panics at
first use — which would have surfaced only when generating a real proof.
Deployed to devnet at 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa. The whole
lifecycle ran against a real validator: pool creation, four deposits, four spends
each carrying a Groth16 proof produced by the host prover and verified by the
deployed program's own syscall, and a settlement paying out exactly four
denominations. The vault ended at its rent-exempt minimum, which is the
accounting invariant closing to the lamport.

A replayed nullifier was rejected with this program's own error code rather than
a bare runtime failure, because an error that could have come from anywhere is
not evidence about this program.

Two defects the live run exposed:

The replay guard sat after proof verification, so a replay cost 94,999 compute
units to reject. A spent nullifier is knowable from one account read; the guard
now runs before the pairing and a replay costs 3,771. That matters because
replays are what an attacker submits in bulk.

The soak reconstructed the host tree from a formula over leaf indices, which only
holds if every deposit ever made used that same formula. The program stores only
the accumulator frontier, so past leaves are not recoverable from the chain at
all. Notes are now persisted to a ledger written after each deposit, and the run
refuses to start when the ledger and the pool disagree rather than building a
root the program has never held.

solana-client is deliberately not a dependency: it pulls transaction-status types
from the far side of the ecosystem's wincode migration and cannot coexist with
the version litesvm needs. The four RPC methods a live run needs are spoken
directly instead, with backoff, since a 429 from a public endpoint is not a
failure of the thing being measured.
… threat model

The first run of the analysis against real mainnet data refused to publish, which
is the fail-closed machinery doing its job: the RPC failure rate was 2.70%,
above the 1% threshold, and a headline computed from that would have been
measuring our own throttling rather than the pool. Both defects below are why.

The provenance client had no retry. The chain client used for the devnet soak
got 429 backoff and this one did not, so a single rate limit abandoned a whole
chain and became an RpcFailure. Enough of those and the run correctly refuses to
report anything. Retries are bounded and persistent throttling still surfaces as
an error — the point is to distinguish a transient limit from a real one, not to
hide either.

The volume-hub rule could never fire on the addresses it exists for. When paging
hit the cap, the collector returned before recording the account's age, and the
rule requires thirty days. So every address busy enough to exhaust the paging
budget — which on the real sample was thirty chains out of thirty-seven — fell
through to unresolved instead of being classified as the busy address it plainly
is. The age is now taken from the oldest signature reached, which is conservative
in the right direction: we stopped short of the true oldest, so an account that
already looks thirty days old from a partial view is at least that.

Both are pinned by tests, including one asserting that a page-capped funder
*without* an age stays a budget outcome rather than quietly becoming evidence.

Also adds docs/THREAT_MODEL.md, which spends more words on what does not hold
than on what does: funding provenance is open and cannot be closed by a better
circuit, the setup is reproducible rather than secure, the program is upgradeable,
submission timing is relay policy rather than a guarantee, and amounts are public.
Leaving it unexplained would read as something unfinished. It is a decision: the
trusted setup is reproducible rather than secure, so proofs are forgeable by
anyone who runs it, and a live pool with that property would be inviting deposits
it cannot protect. Advertising a mainnet address while publishing a threat model
that says proofs are forgeable would be incoherent.

Devnet demonstrates everything mainnet would — same runtime, same alt_bn128
syscall, same verifier, same bytes. The prerequisite for mainnet is a real
multi-party ceremony, not more SOL.

Also adds the unresolved bracket to the metrics, which the methodology requires
before any point estimate is publishable: unresolved members merged into one
class is the most favourable reading, split into singletons the least, and both
are reported because neither is known.
…could not pass

An adversarial review found no theft path — the accounting invariant holds, the
circuit is sound, nullifiers are spend-once and duplicate accounts in one batch
fail closed. What it found instead were ways for a third party to permanently
destroy a member's deposit for free, which loses the same money by another route.

action_accounts was relay-supplied, written into the spend record without
validation, and outside the action binding. A relay handed a valid transfer proof
could submit it with a count settlement can never satisfy: the pairing passes
because the binding did not cover the field, the nullifier burns, and settlement
then refuses forever. No instruction amends or refunds a spend, so the note is
destroyed at zero cost to the relay beyond the fee it forfeits. That falsified a
sentence in the threat model — "the binding covers every field with economic or
behavioural meaning" — which was simply not true. The field is now in the
preimage, and selector and count are validated at submit rather than at
settlement, because reaching settlement already means the nullifier has burned.

k_floor had no upper bound. Pools are unique per denomination and creation is
permissionless, so a griefer paying one pool's rent could set a floor above the
tree's capacity and make every spend fail permanently — taking every later
depositor's funds with it and preventing an honest pool for that denomination
from ever existing. Bounded now between 2 and the tree capacity, and the entry
fee must stay below the denomination. A floor of one was also accepted, which is
the anonymity set of one the check was supposed to prevent.

Regressions for both, including one asserting the note is still spendable after a
failed griefing attempt.

`make verify` also could not pass on a fresh clone: it ran the tests before
build-sbf, and the end-to-end suite loads the .so that build-sbf produces. The
one command the README tells a reader to run failed at the first thing they would
try. The ordering is fixed and checked by deleting the artifact and running it.

CI ran the host checks and the SBF build as separate jobs on separate runners, so
twenty end-to-end tests ran in a job with no Solana toolchain and could only
panic, and the artifact never crossed between them. CI now runs `make verify` —
the same command the README documents — so the two cannot drift apart again.
Every action test passed zero accounts, so the loop that reads them and the
metas built from them were dead code — in the submission's headline feature.
Memo requires every account handed to it to have signed, so passing one makes
the account list load-bearing: a dropped account, a mislabelled signer flag or a
miscount now fails.

Building that test found a real constraint. Appending the vault to the callee's
account list breaks the runtime's balance check, because settlement has already
moved the payout out of the vault by direct mutation and the runtime then
reconciles those lamports across the CPI boundary. Marking it read-only does not
help. So the vault authorises through its seeds and is never one of the callee's
accounts, a settler that tries to place it there is refused, and THREAT_MODEL.md
now states the consequence: an action whose target needs the pool itself as an
account is not expressible in this version.

Also documents that the binding covers how many accounts an action takes but not
which — settlement is permissionless, so a settler picks them. For a target whose
destination is an account rather than instruction data that is a real limit, and
the README no longer implies otherwise.
A documentation fact-check against the source found twelve statements a judge
could disprove by running one command. Corrected:

- the test count said 152; it is 179
- the headline compute figure said 99k; the only number this repo measures is
  submit_spend at 97,860 CU on a real SVM. The 74,179 + 5,661N table in the
  Groth16 notes comes from a scratch circuit that is not in this repository,
  which the file now states
- four docs credited the pool PDA with signing actions; invoke_signed uses the
  vault's seeds, and one file contradicted itself two sections apart
- "Actions carry one signer" stopped being true when the vault stopped being one
  of the callee's accounts; it is now "one caller", which is what holds
- the arity table listed the action binding as an arity-4 Poseidon; it is a
  keccak digest, as the code's own comment already said
- "all three checked against circomlib" was true of two. The gadget and the host
  are pinned to the published vector; the syscall is checked against the host
  on-chain. The payoff clause did not follow from the premise
- the binding's field list appeared three times, each missing a different field
- two CLI command lists omitted verify-setup and soak, including the README
  table, while the README leans on verify-setup elsewhere
- "Two commands" introduced three
- the README promised a headline recomputable from a file that does not exist.
  The committed artifact is data/sample-privacycash.json, and it recomputes a
  refusal rather than a headline, which the README now says
- a source comment claimed a replay costs about 3k compute units; measured, 11k

Also removes sp4: twenty-six files of vendored solana-program source, committed
by accident while inspecting an API and referenced by nothing.
…egative case

verify-setup regenerated a key, printed its hash and exited zero. It compared
against a digest the caller supplied and never read the key compiled into the
program, so running it with no arguments verified nothing while the threat model
claimed it let a third party confirm the deployed key matches this circuit.

It now compares element by element — alpha, beta, gamma, delta and every IC
point — against the program's baked key, and the expected digest is published so
there is something to check against. With a wrong seed it names which elements
diverge. That is the whole point: a ceremony verifier comparing only delta, as a
competing submission does, would certify a key belonging to a different circuit.

Seven negative tests asserted only that something failed. The standard PROOF.md
sets is that a negative case is evidence only if this program's own error code is
what rejected it, and these would have passed on a runtime failure or an account
miscount. Each now pins its code. The one case genuinely caught by the runtime
before the program can rule on it asserts that explicitly, so the exception is
visible rather than looking like an oversight.

Adds a test for the vault-in-action-list refusal, which the threat model asserted
and nothing checked, and replaces an assertion comparing two freshly generated
keypairs — trivially unequal, proving nothing about the property it named.

PLAN.md now records the six things that did not ship as decisions with reasons,
including one claim withdrawn rather than descoped: a program cannot check
funding provenance, so a metric gating settlement was never deliverable.
…ced nothing

Run 3 is the first clean census: 84 members attempted against a live pool's
depositors, 40 resolved, zero RPC failures. Loss factor 0.1219, inside an
unresolved bracket of 0.0253 to 0.1837.

The tool still declines to call that a result, on two gates. Under half the
members reached a class, so the bracket's readings differ more than sevenfold and
either quoted alone would describe the sampling budget rather than the pool. And
Chao1 estimates 108 provenance classes against 17 observed, so most of the
distribution was never seen. Both are stated beside the number rather than under
it.

Run 2 is logged as the failure it was, because a log that keeps only successful
runs is selecting rather than reporting. It refused at a 14.94% failure rate, and
the cause is worth recording: HTTP 429 arriving at 0.7 requests per second, far
under any documented limit, because providers meter by compute units and a
thousand-signature page is expensive in those terms. Lowering the request rate —
the obvious response — would not have helped. The cause was a page cap of 60, set
on my mistaken belief that paging deeper would resolve more chains. It would not
have: the volume-hub rule needs about eight pages to clear its threshold and
estimate an age, and the other fifty-two were waste that spent the budget which
made the endpoint refuse. Run 3 uses a fifth of the calls and resolves nearly as
many members.

That diagnosis was only possible after instrumenting the collector, which had
been collapsing every failure to RpcFailure with no cause — honest in its census
and undiagnosable in practice.
Every PDA this program creates is derived from public data, and
system_instruction::create_account fails outright when the destination already
holds lamports. A nullifier is visible in the submit_spend instruction itself, so
anyone who saw the transaction — most obviously the relay it was handed to —
could send 890,880 lamports to that spend PDA first and make the note
permanently unspendable. There is no refund instruction, so the deposit was gone.
The same trick against a pool or vault address denied that denomination's pool
forever, since pools are unique per denomination.

Replaced with the three-step create: top the account up to rent exemption, then
allocate and assign under the PDA's own seeds. A squatter can send lamports but
cannot allocate or assign without those seeds, so their transfer now only
reduces what the legitimate payer owes.

The replay guard also keyed on lamports, which had the same flaw in reverse: a
stranger funding the PDA made the program report the note as already spent. It
now keys on allocated data, because anyone can send lamports to a public address
but only this program can allocate it.

Two tests, both of which fail against the previous code: a squatter front-running
a spend, and a squatter denying pool creation.
…and the tests did not

The signing selector shipped with a test that settled it alone, and that shape
is the one shape the bug does not appear in. On devnet, with three transfers
ahead of it in the batch, settlement failed: UnbalancedInstruction.

The runtime's objection is not to a payout preceding *that member's* call. It is
to any lamport this program moved anywhere in the same instruction, which in a
batch means the members settled before it. So the constraint is a property of
the batch, and one pass over the spends cannot satisfy it.

Settlement now reads the whole batch, runs every call the pool signs while the
vault is still untouched, and pays everyone afterwards. Selector one keeps
funding before its own call, because it needs to and can: it never hands the
vault to the callee.

The shape is the point, not just the fix. A crowd is mixed by definition, and a
signed action that could only settle alone would have to wait out the timeout
instead of joining a batch — giving up the shared timestamp that is the reason
to stand in the crowd at all.

Devnet carries it now. The settlement in PROOF.md is four spends in one
transaction, one of them the pool invoking SPL Memo as a member's authority, and
the evidence is Memo's own log naming the vault as a signer. The soak reads that
line back off the cluster and fails the run if it is absent, so the section
cannot be published without the callee having said it.

Two soak defects found while getting there, both of which cost a run:

- The note ledger was one shared file while the pool is per-denomination, so the
  guard that says "use a fresh denomination" produced a ledger that disagreed
  with the fresh chain. The ledger is keyed by denomination now.
- Reusing a pool that still held unspent notes ran the whole positive path and
  then failed at the accounting assert, after the deposits had been spent to get
  there. It is refused up front instead, with the reason.

198 tests. Redeployed to devnet at the same program id, PROOF.md regenerated.
The batch-level ordering constraint, the devnet evidence, and the negative-case
count all moved with the feature, and three documents still described the state
before it.

THREAT_MODEL counted 'every negative case but one' as carrying our own error
code; there are two now, and the second is the vault-drain attempt, refused by
the System Program's ownership rule rather than by us. Saying so matters more
than the count: it names what the safety actually rests on.

README and ARCHITECTURE carried the old devnet accounting, 80,000,028 against a
run that has since been replaced.

198 tests, make verify green.
The signing selector had SPL Memo behind it, which proves the mechanism and
nothing about the use. This is the use: a real stake account on devnet,
delegated by the pool, settled in the same transaction as three plain transfers
and a memo.

    Delegated Stake:        1.09771712 SOL, activating
    Delegated Vote Account: 2f9C9AU8nFRKUub8NHToNiZzcwmYiNeipVuP8akKgRVv
    Stake Authority:        CWxsJdxBLm3LC6dEnBco68a6T4QNEF31N3qQRy95wN3Q
    Withdraw Authority:     H9DRVAD42eiQqmXeYrX5AWwoYRr4wie4MzvJDC6Wkxmn

DelegateStake requires the staker authority to sign, and a member who signs it
has appeared on chain and undone the point. So the pool signs. An observer gets
the validator, the amount and the timing, and cannot say which of the five
members asked for it. That is the entire thesis in one transaction, and it is
the first artifact here where the anonymity claim is about a behaviour rather
than about a payment.

The stake authority is the vault. The withdraw authority is the operator, and
the asymmetry is the threat model applied rather than repeated: the pool's
signature is available to every member, so an authority the vault holds is one
every member holds. Delegation survives that — the worst a member can do is
re-delegate somebody's stake. Withdrawal does not.

The member's escrow is inside the delegation rather than beside it. The stake
account is the spend's beneficiary, so the payout lands there: 1.1 SOL from the
operator plus 0.0198 from the member.

The account list came from the CLI rather than from memory. DelegateStake takes
six accounts with the authority last and the deprecated config account still in
the middle; getting that order wrong is not a compile error and not a clear
runtime one either — the stake program would read config as the authority and
report a missing signature. Dumping an unsigned message cost nothing and removed
the guess.

Verified twice over, neither time by us. The soak reads the account back and
refuses to publish unless it has reached the Stake variant, which an initialised
account has not; and the solana CLI's own stake-account command agrees from
outside the repository.

198 tests, make verify green.
PLAN.md still described the measurement method as targeting weaknesses found in
other people's work. Same objection as before: a reader cannot audit work they do
not have, and the three failure modes stand on their own — selection that drops
whatever is expensive to trace, a sample of seconds presented as a population, a
class key at raw-address resolution. Stated as failure modes, they are checkable
against our own method.
The README said 'measured on a live pool's depositors' and left it there. The
pool is Privacy Cash, an unrelated live mixer, and a reader who assumed the
number described mirror-pool would have been wrong in the flattering direction.

The reason is good — mirror-pool has no depositors, so measuring it would be
measuring nothing — but the reason belongs in the README rather than only in the
measurement log, and the fact that this is a tool pointed at somebody else's
protocol is a feature to state plainly, not a detail to leave implicit.
PLAN.md shipped a four-day schedule, an hour count, a list of what would be
dropped first if time ran out, and a stretch section describing a contribution
to a different repository as a second prize with a separate champion. None of
that is about the protocol, and the last of it is strategy rather than work.

What survives is the part a reader can use: the thesis, the design decisions and
their reasons, and the record of where the shipped protocol departs from what
was designed. That record is the reason to keep the file at all, so it is now
the file's second half rather than a footnote to a plan.

PROVENANCE_METHOD's prior-art section instructed itself — "claim exactly three
things, all defensible", "useful framing", "do not cite" — which shows a reader
the seams of the positioning instead of the finding. It now states the three
claims, each with the check that would falsify it, and records the withdrawn
paper as a fact rather than as an instruction. The methodological prescriptions
elsewhere in that file stay: telling a reader not to use a set-valued class key
is the method, not a note to self.
…er seen it

The tool had nine commands and none of them let a member do anything. Two were
trusted setup, six were the measurement, one was a scripted demo. Somebody who
wanted to deposit had to read soak.rs and reimplement it. For a tool, that is
the gap that matters most.

Six commands now cover the whole journey: init-pool, note-new, deposit, tree,
spend, settle. Every one of them was run against devnet to produce the output in
docs/USAGE.md; nothing there is illustrative.

`tree` is the piece the rest stands on. The program keeps only the accumulator's
frontier — one node per level, enough to append a leaf and produce a root, not
enough to prove a particular leaf is in the tree — so a client needs the whole
leaf set. The soak had been carrying a local ledger file to dodge this, which is
convenience masquerading as necessity: every commitment was an argument to a
Deposit instruction, so the set is in the transaction history in insertion
order. `tree` reads it back, rebuilds the accumulator, and checks the root
against the pool's. A match proves the recovered set is complete and correctly
ordered, which is exactly the precondition a membership proof needs.

That is the difference between a demo and a tool. A member with their note file
can act from a fresh machine. No indexer, no server, no account with anybody,
and no operator — us included — between a member and their own money.

The scan reads the *pool* account rather than the program, because a program
hosts one pool per denomination and scanning the program would walk every pool's
history to build one pool's tree.

On the experience, three decisions worth naming:

- The note file refuses to be overwritten and there is no --force. An overwrite
  destroys a deposit.
- A note whose commitment disagrees with its secrets is refused before it can be
  spent. Spending it would burn a nullifier against a leaf that is not in the
  tree — the member loses the deposit and learns nothing about why.
- Below the crowd floor, `settle` says how many spends are pending, what the
  floor is, how long the youngest has waited and how long remains. The
  alternative was a failed transaction carrying custom error 0x15.

`settle` executes plain transfers and leaves CPI actions alone, reporting each
one. The record binds how many accounts a call takes and never which, so no tool
can infer the account list a callee expects; pretending otherwise would produce
confident wrong guesses.

Ten tests on the new code, all offline: the note round-trip, three ways a note
file can lie, the overwrite refusal, and — the one that matters — that a tree
rebuilt in the wrong order produces a different root. That property is why the
scan reverses the RPC's newest-first paging, and without the test it would be a
comment.
…eleventh

"Scalable" was a word in this repository until now. The number is ten, and both
sides of it are demonstrated rather than one.

    settled 10 spends in one transaction: 1228 bytes (4 to spare), 19545 CU of 200000
    11 spends: rejected by the wire at 1327 bytes, 95 over the 1232-byte limit —
    while the SVM settled the same batch in 24158 CU, so compute is not the constraint

The packet limit binds and compute is not close: a full batch spends under a
tenth of the default instruction budget. Reporting only the failure at eleven
would have left the reason ambiguous, so the test replays the identical
eleven-spend batch into a second pool and watches the SVM settle it. If compute
ever became the binding constraint, that assertion fails rather than the headline
quietly becoming wrong.

Two honest edges, both in the test rather than around it. Ten is the worst case
— every spend brings three accounts no other spend shares, and a batch with a
shared relay names fewer keys and fits more. And it is a ceiling per transaction,
not per epoch: a busy pool settles in several batches and pays for it in
timestamps, which is a real cost to the anonymity and the reason to publish the
number.

Also refuses the mistake that undoes the protocol. `spend` now reads the pool's
depositors out of the same scan it was already doing and refuses to relay through
a key that deposited. The relay signs, so its key is public on the spend; a key
on both sides links them and this action's anonymity set collapses to one. It is
the easy mistake, because the obvious key to hand --relay is the one already
funded — the one that deposited. Verified against devnet by making it.

209 tests.
The note ledgers of past soak runs were tracked. They are one operator's
state against one cluster, they mean nothing to a reader of this
repository, and each one holds the secrets that release its pool's
escrow. Untracked, and ignored along with the validator-choice plans the
crowd runs write.
PROOF.md shows the pool delegating stake as a member's authority once, in
a batch whose other members were paying. That is the easy case for an
anonymity set: nothing told the members apart to begin with. The question
left open was what happens when the actions diverge in content — when
each member picks their own validator, which is the behavioural pattern
that actually fingerprints a staker across epochs.

`mirror crowd` settles six delegations to six different devnet validators
in one transaction and reads every stake account back off the cluster,
because the proof binds how many accounts a call takes and never which:
before settlement nothing on chain says which validator member 3 wanted.
The read-back is the only check that limit can have.

The cost is measured rather than argued. A member who picks their own
validator names a vote account nobody else names — one extra distinct key
per spend — so the batch loses a member: seven if the crowd agrees,
six if it does not. Taken twice by paths that share no code, litesvm in
action_ceiling.rs and the live run, both landing on 1194 bytes.

Two things this turned up that were not expected:

Compute is not the afterthought it is for payments. Ten transfers settle
in 19,545 CU; six delegations took 142,856 of the 200,000 a single
instruction gets. At that ceiling both exits are shut — asking for a
larger budget costs a second instruction worth 40 bytes, and the batch
has 38 to spare.

And the run has to survive its own network. A public endpoint timed out
mid-confirmation with six deposits already made, which under the old
rules meant escrow nobody could release. Resuming now rebuilds from the
chain rather than from a file: a note's nullifier fixes its record's
address, and the record carries the accounts settlement must name.

Also extracts stake.rs, because the DelegateStake layouts were about to
have a second copy, and persists each run's results so that fixing a
sentence in a generated report does not cost another live run.
An audit of everything the repository publishes, against the code and
against itself.

The worst of it was compute. Every CU figure in the docs was a print
from a test run, never asserted, and settlement's cost moves by
thousands between runs because each run derives its record addresses
from freshly generated nullifiers and find_program_address searches a
different number of bumps. Three runs here gave 40251, 41751 and 46251
for the same batch; the docs said 39,820. GROTH16_INTEGRATION.md even
named the command a reader should run to reproduce its number, which is
the one command that falsifies it.

So the tests now assert bounds and the docs quote bounds. A figure that
would fail on nothing was worse than no figure.

Three claims were wider than what supports them:

The selection check published two rows as budget margins. Only one is.
The staking pair used identical parameters — depth 16, page cap 24 — and
differs by the tracer fix, so its "expensive to trace" group is members
the broken tracer failed on, not members a smaller budget could not
reach. Still a real check, a different one, and now labelled as such.

"A third of the cost per member" was two-thirds: 1,776 calls for 54
resolved against 1,887 for 38.

The measurement log's closing section was four runs behind — it quoted
the broken-tracer headline, said only one pool had been measured, and
counted four runs where there are eight.

The rest is smaller: a paragraph duplicated verbatim, six samples that
are seven, command lists missing every member-facing command, an
artifact layout and two commands the method specified and nobody built,
and a quick-start block whose five commands were all missing required
flags — printed directly under the new install section, so a reader
would paste it and get five clap errors.

Also documents TREE_DEPTH and ROOT_HISTORY, which appeared in no
document, and adds the prerequisites: make verify needs the Agave
toolchain for cargo-build-sbf, which was stated nowhere outside CI.
… up is a liability

Three things, and the first is a correction.

**Ten was a property of the client, not the program.** The batch ceiling
this repository published came from settling as a legacy transaction,
which names every account by its full 32 bytes. A v0 transaction can name
the same accounts by one byte each through a lookup table, and
settle_epoch has always been compatible with that: it requires a
signature from the settler and from nobody else, and a lookup table can
serve any account that is not a signer. The word "legacy" appeared once
in the whole repository, in a test comment, while README, USAGE and CROWD
all stated ten as if it were the protocol's limit.

So mirror settle now publishes a table for any batch that will not fit
legacy, and a_lookup_table_takes_the_packet_out_of_the_way pins the
arithmetic against the executed 1228-byte figure so the two cannot drift:
sixty members fit inside a packet, six times what legacy allows, nowhere
near a limit.

The table is taken back down. It costs rent, but the reason to close it
is that while it exists it is a public durable account listing every
address the settlement is about to touch, published before the settlement
lands — a permanent on-chain index of the batch, which is a strange thing
for a privacy pool to accumulate one of per round.

**Disclosure, to one verifier, chosen by the member.** No viewing key, no
auditor role, no on-chain component and no path the protocol can be made
to produce one down — a compel path that exists is a compel path that can
be used. mirror disclose writes a file; mirror disclose-verify recomputes
every value in it against the chain and reports ten checks separately, so
a tampered field fails on its own name. Thirty-one tests, one per tamper
case, each asserting which check failed rather than merely that
verification did. It refuses before settlement, when the secrets are
still the deposit rather than a proof of it, and it refuses when naming
one action as yours would leave the rest below the pool's floor — the
people that costs are not the discloser.

**The submission phase, stated as the residual it is.** Settlement is one
signer and one timestamp, and that is only half of what an observer sees.
submit_spend is one transaction per member, publishing the beneficiary
and the action in cleartext at a moment of the relay's choosing, in an
order nothing shuffles, signed by a key that is the member themselves if
they relay for themselves. A threat model scoped to the settled
transaction is describing a smaller adversary than the one that exists.

Also: a drift guard comparing the compiled verifying key against what the
published seed derives, element by element, so the digest in the README
is enforced rather than left to an operator remembering to run a command;
cargo-deny with every advisory ignore carrying its reachability argument;
and CI actions pinned to commit SHAs, because a tag is a mutable pointer
that runs with this workflow's token.

256 tests.
Rebuilding a pool's accumulator costs one getTransaction per signature,
and that is the method public endpoints throttle hardest. The client sent
the whole burst back to back and gave up after seven attempts, so on a
pool with real history `mirror spend` failed with a rate-limit error at
the one moment a member needs it — trying to spend a note they own.

Two changes, both about the same failure. The rebuild now pauses briefly
between transaction fetches, which costs seconds and removes the burst.
And the retry budget goes to ten attempts with the backoff capped at
thirty seconds, so a client waits about four minutes before concluding an
endpoint will not serve it, rather than about one.

Found by running the tool against a pool with two dozen deposits, which
is the first time the scan was long enough to matter.
Found by settling two dozen spends against devnet, which is the first
time any of them could happen.

**The account-lock limit is what binds once a table is in play.** Twenty
members through a lookup table weigh 332 bytes of 1232 — the packet is
not remotely the constraint any more — and devnet refused them for
locking 77 accounts. The SDK exports MAX_TX_ACCOUNT_LOCKS = 128, but that
is the raised limit and it is not live here; the number a client must
plan against is 64 until it can see otherwise, so that is the number,
with a comment saying where it came from. Settlement now adds members
while the batch still fits and defers the rest, so a settler never has to
know the limit exists.

**The crowd rule was checked against the wrong batch.** It ran before the
lock limit truncated the batch, so a pool with 24 pending and a floor of
24 passed the check, sent 20, and was refused by the program with a bare
0x15. It now checks what is actually being sent, and when the floor is
above what one transaction can carry it says so — that pool can only ever
settle on the timeout.

**A lookup table must be derived from a finalized slot.** The address is
seeded with a slot the program validates against SlotHashes, which holds
rooted slots only, so a confirmed slot — routinely ahead of rooted —
produces an address rejected with "is not a recent slot": an error that
names the cause and sounds like its opposite, since the slot was too new.

And init-pool now refuses a floor higher than one settlement can carry
rather than letting it be discovered later. A pool's floor is fixed at
creation, so by the time anyone notices, the fix is a different pool.
That mistake is how the lock limit was found.
Publishes what the devnet run took rather than what the arithmetic
predicted: twenty members in one transaction, one signature, 332 bytes of
1232, 64 account locks, 35,895 CU.

The correction matters more than the number. CROWD.md said that at the
six-member divergent ceiling "the only two exits are closed at once" —
the packet, and the compute-budget instruction that no longer fits beside
it. That was true when it was written and a lookup table is a third exit,
so it is false now. What replaces it says which ceiling is which: six is
the *legacy* ceiling for divergent delegations, and the ceiling through a
table is unmeasured for that shape. Asserting the legacy number carries
over would be the same mistake in the other direction.

The lookup test now asserts both constraints in one place, because
"sixty members fit inside a packet" is a statement about bytes and a
reader could take it for a member count — sixty members lock 183 accounts
and the cluster would refuse them. Twenty is exactly 64 locks, and
twenty-one is over; both are pinned.

Also fixes the subcommand accounting, which had drifted twice: the count
was stale after three new commands, and my first correction left
init-pool out of its own list.
Replaces the constructed examples with devnet output. The disclosure is a
real one: a member proving after the fact that the pool-signed stake
delegation in CROWD.md was theirs, ten checks, every value recomputed
from the secrets or read off the cluster.

The table close is real too — 15,084,280 lamports back and the published
address list gone, on the table the twenty-member settlement used. That
is the half of the lookup-table story that is easy to skip, and skipping
it leaves a permanent on-chain index of every cohort a pool ever settled.
A member is paid denomination - relay_fee, and that figure lands in a
public account balance. The program bounded the fee only by
relay_fee < denomination, so two members settled together could receive
visibly different amounts — and an observer partitions the batch by value
without breaking a proof or learning a secret. The crowd rule, the shared
timestamp and the single settling signature all exist to prevent exactly
that partition, and a fee that varies handed it back.

The check goes in settlement rather than submission because it is a
property of the batch and not of any record: a member may agree any fee
with their relay, and settles with the members who agreed the same one.
FeeNotUniform, code 25, refused whole — neither member is paid and
neither record is consumed, so both can still settle with somebody who
paid what they did.

mirror settle now groups pending spends by fee and takes the largest
group, rather than assembling a transaction the program will reject.

What this does not fix, and the threat model says so: a fee nobody else
pays is still a crowd of one. The rule turns a silent partition into a
visible one; choosing a common fee stays the member's job.

Both tests assert the property rather than the error code alone — the
refusal leaves both records pending and both beneficiaries unfunded, and
the uniform batch pays every member the same number.
**The crowd rule is threshold-or-timeout and the timeout side has no
floor.** A batch of one settles and executes, an hour after submission,
and settlement is permissionless so an adversary may be the settler and
compose that batch. k_floor bounds a batch that settles by crowd and
bounds nothing about one that settles by clock. The threat model now
argues that rather than leaving it to be found: why the trade is still
right — the alternative freezes a quiet pool's escrow with no authority
able to release it — what a member can do instead, which is traffic and
not the program, and what would actually fix it, which is refunding
rather than executing a batch that never reached the floor. That is a
different protocol and it is named because it is the honest answer.

`mirror settle` now warns when it is about to publish an under-floor
batch, and says plainly that a batch of one gives an anonymity set of one.

**And CROWD.md runs our own metric against our own crowd.** It returns
ρ = 1.0000, the best value the metric can produce, and the section exists
to say why that is worthless: every note in that pool was funded by one
wallet, so the partition has a single class and the class is us. The
metric correctly reports no loss through the provenance channel and
cannot report that the channel which failed was a different one.

That is the tautology PROVENANCE_METHOD §9.0 warns about, met head-on. A
measurement apparatus that only ever points outward is one nobody has
tested, and the published headline is only worth anything because it
points at a pool this project neither controls nor funded.
Every field in a submit_spend travels in clear text, so a bystander
watching an unlanded one could copy the proof, name themselves as the
relay, and land it first. The recomputed binding was unchanged, the
pairing succeeded, the nullifier burned, and the fee settled to them.
Binding the amount without binding the payee is half a binding.

The relay now goes into the action binding, read from the account that
signed rather than from anything the caller states, so a proof is
spendable only by the relay the member made it for. ACTION_DOMAIN moves
to v2: the digest would differ anyway, but the version says why, and a
prover left on the old shape fails as a version mismatch instead of a
mystery.

It was never a profitable theft, and the threat model says so rather
than overstating the fix. Whoever lands the transaction funds the spend
record's rent, and that record is never closed because it is the replay
guard, so at the fee levels used here an attacker sank roughly twenty
times what they collected. The reason to close it is that an attacker
need not be paid to be effective: front-running every spend denies every
relay its fee, a relay whose fee can be sniped is one nobody runs, and a
pool with no relays is one where members submit from their own funded
wallets. The attack never touched the cryptography — it made the honest
path uneconomic and let members deanonymise themselves.

The circuit is untouched. The binding is a pass-through public input —
the constraint system only squares it so it is not optimised away, and
the keccak happens on both sides outside — so the shape, the verifying
key and the published digest are all unchanged, and vk_drift still
passes.

Two tests, and a fourth negative case that is the one the other three
miss because the attacker alters nothing: same beneficiary, same fee,
same genuine proof, and only the signer differs. It is rejected on
devnet with 0x12. The replay test's comment claimed the relay was not
bound and had to go; the assertion on Custom(15) survives because the
nullifier guard runs before the binding is recomputed, which is now
what that test pins.

PROOF.md's rejection count is derived rather than written by hand. The
fourth case had turned "the last two attack a live note" into a false
sentence under a correct table — the failure this generator has already
been bitten by once.
The brief asks for a coordination layer, a privacy set, and the incentives
that keep people in it. The first two were argued at length and the third
appeared nowhere outside PLAN.md, where the only thing said about it was
that the designed version had been cut. Read straight through, the
repository looked like it had two of three.

It has four, and they were all already in the program:

- k_floor refuses a spend until a crowd exists, so arriving early is what
  lets other people act rather than something the pool punishes
- the one-hour timeout is what makes that floor an incentive instead of
  coercion; the two rules only work as a pair, and removing either turns
  the other into a reason to leave
- the relay fee pays somebody else to sign, which is what makes the
  anonymity-preserving path economically available and not merely
  permitted
- FeeNotUniform refuses a mixed-fee batch outright, so converging on a
  common fee is a rule rather than advice in a usage guide

INCENTIVES.md states each with the line that enforces it, then states the
gap plainly: nothing pays a member for dwell. It also says why the
obvious fix is the wrong one. A reward has to be paid to somebody, naming
an address on chain is the one thing this design spends everything else
avoiding, and a per-identity reward counter and an anonymous path are
mutually exclusive by construction — so the mechanism would pay people to
destroy the behaviour it exists to reward.

The version that would work is specified rather than gestured at: a
second nullifier per note, claimed against the same membership statement,
gated on the same floor, paid to a fresh address, with dwell read from
data already on chain so no per-identity counter is stored. That is a new
instruction, a new nullifier domain, a changed note format and a changed
circuit, and this repository's standard for a value-bearing path is a
real SVM with negative cases and a live cluster with published
signatures. Absent rather than half-present, and scoped rather than
vague.

Also adds the README section that answers the three parts in order, since
a reader looking for the brief's own words should not have to assemble
the answer from four documents.
…ad left open

The last version of that document ended by naming what it had not
measured: "the ceiling through a table is unmeasured — not that it is the
same number." This answers it with the same method the packet ceilings
use, by building the real instruction and counting what it names.

    batch                          legacy    through a table
    everyone to one validator        7            18
    a validator each                 6            13

Both roughly double, and the interesting part is that the gap between
them widens from one member to five. Bytes are spent naming an account;
locks are held per distinct account. A shared vote account is named once
either way and locked once too, so agreeing on a validator is worth more
under locks than it was under bytes — the divergence trade this whole run
exists to price gets steeper in the regime where the batches are bigger.

The compute-budget instruction is inside both figures because at this
size it stops being optional: thirteen delegations run to roughly 309k CU
at the per-member rate the live run measured, well past the 200,000 a
transaction gets by default. Asking for more brings the compute-budget
program, and a program is an account. So raising the budget still costs a
member — forty bytes in a legacy packet, one lock through a table. The
escape from one limit is paid out of the other in both regimes, which is
the result rather than the annoyance.

What these two are not: a settled batch of that size. The document says
so in those words. The 64-account limit they are measured against is not
a guess — it came off devnet, where 77 accounts returned
TooManyAccountLocks and the twenty-transfer settlement landed at exactly
64 — but a thirteen-member delegation through a table has not been sent
and is not claimed.

render_only recomputes these three fields instead of reading them. A
record written before they existed deserializes them as zero, and a zero
rendered into a published table would read as a measured result, which is
worse than any staleness the recompute could introduce. It touches no
cluster: same instruction, same count.
The evidence was all here and a reader had to assemble it. Someone
arriving with the brief in hand had no row to check "Rust, end to end"
against, no single place saying what is different, and no architecture
short of the long version. Three sections now sit above everything that
was already written, and nothing below them was removed.

**Meeting the brief** is a table, one row per thing asked, with a third
column saying where to check it rather than asking to be believed. The
Rust-only row carries the command that proves it and returns nothing.

**What is different here** is six claims, each phrased as a fact about
this repository. Every number is taken rather than modelled, and the one
figure that is derived rather than landed says so where it is published.
The setup is reproducible and a test compares the whole verifying key
rather than one element of it. The pairing runs on chain in the program's
own syscall. A note pays out once ever, and the denomination is a pool
constant rather than a note field, so the class of bug where escrow and
payout disagree cannot be written down. Every command the documents
mention exists. And the metric is turned on our own run, where it returns
its best possible value and the section explains why that is worthless.

**The architecture, in one page** gives the two-phase shape, why it is
two phases — n proofs inside one settlement would blow the budget at six
members — what the three public inputs are, and why the pool signs. The
long version stays where it was.

Also adds the delegation ceilings through a lookup table to the crowd
section, and moves the test count to 261.
Four gaps from the review, and one number of mine that was wrong.

**What a settlement ceiling is not.** The repository leads with "twenty
members in one transaction" and never said, anywhere, that this is not
the anonymity set. It is not: the set is the tree, twenty levels deep, so
a pool holds up to 1,048,576 notes and a proof costs the same at any
occupancy. A settlement bounds how many members share one timestamp,
which is a different and narrower thing — a pool with more members than
one batch holds settles in several and pays for it in timestamps, not in
set size. Without that paragraph the headline number reads as a cap on k,
which is the opposite of what it measures.

I first wrote that tree as depth 16. It is 20. The wrong number was worse
than no number, since the whole point of the paragraph is that the set is
large.

**verify-setup, as a command a reader can run.** The reproducible setup
was claimed in prose and buried as a subcommand in a table of eighteen.
It is now the differentiator it should be: one line, the real output, the
published digest, and the reason the check binds the whole verifying key
rather than delta alone — a transcript that bound only delta would
certify a key belonging to a different circuit. With the pointer to why
reproducible is not secure, because that distinction is the reason this
is on devnet.

**Why the bracket is not decoration.** Twenty-nine of eighty-three
members did not resolve to a class. Counting them as one class each puts
the loss factor at one end of the range and folding them into observed
classes puts it at the other; both are assumptions, so the interval is
the honest report. A figure published without one has quietly picked an
assumption, which makes it partly a measure of how much of the graph the
tracer could afford to walk. That argument was in PROVENANCE_METHOD.md
and belongs beside the number.

**docs/INTEGRATING.md.** The brief asks for easy extension with new
protocols and the answer was diluted across a usage section. Adding a
protocol needs no program change, no redeploy and no new circuit, and
this is the four-step procedure with a worked DelegateStake — payload,
account count, why the pool must sign, and why its withdraw authority
deliberately is not the pool. It also states what the proof does not
promise, which is which accounts fill the declared slots, and points at
the run that checks that limit by reading every stake account back off
the cluster rather than asserting it.

Portability is now stated positively — same source, real proofs, x86_64
in CI and arm64 locally, no feature flags between them — rather than as a
denial that anything is architecture-specific.

Not done, and each for a reason: reporting a worst case per bucket
alongside the loss factor would contradict the argument this README
already makes, that "worst case is 1" describes heavy-tailed provenance
in general and not the pool being measured. Naming the disclosure checks
in USAGE.md was already done — the walkthrough prints all ten by name.
The rest was polish.
README and ARCHITECTURE both said "100,000,095 owed against 100,000,095
paid out". The soak's clean-pool re-run moved the denomination from
20,000,019 to 20,000,023, and five notes at the new one is 100,000,115 —
which is what PROOF.md says, what the arithmetic gives, and what the
vault actually paid: 100,890,995 down to its 890,880 floor is a debit of
100,000,115 to the lamport.

Nothing under it was wrong. The invariant holds in the program, the soak
asserts it and fails the run otherwise, and the generated evidence
carried the right number the whole time. What was wrong was a figure
copied into prose by hand, in a repository whose argument is that every
number is checkable — which is the one place a stale number costs more
than it looks like it should.

Found in both files rather than one: the sweep after changing the
denomination went looking for the denomination and not for the figures
derived from it.
Writing the work up as an article forced a per-run table, and the table
disagreed with two sentences we had already published.

MEASUREMENT_LOG said the honesty machinery "refused three consecutive
runs, twice on the failure gate and once on the bracket." The per-run
detail in the same document says otherwise: Run 1 resolved nothing with
zero RPC failures, so no gate fired and there was no figure to refuse;
Run 2 was refused by the failure gate at 14.94%; Run 3 by the
informativeness gate, 40 resolved against a threshold of 42. One gate
each, not two of one and one of another.

The README undersold the result. It said eight runs "including the three
the tool itself refused", which reads as three failures out of eight.
Six of the eight end without a publishable figure: one that resolved
nothing, one refused on failures, three refused on informativeness, and
one invalidated by the defect we found in our own tracer. Only Runs 4 and
6 produced a figure, and Run 4's was superseded by the correction.

Six of eight is the stronger sentence as well as the true one. A
measurement programme that publishes its refusals is worth more the more
refusals it had, and rounding them down to three gave away the argument.

Also adds the article itself, which argues both halves of the work as one
claim, and fixes two things it caught: the lock decomposition in the
README summed to 63 because it omitted the program account — which no
lookup table can resolve, since a top-level instruction names its program
in the static section — and the Serjantov, Dingledine and Syverson paper
is "Active Attacks on Several Mix Types", not "…Mix Batching Strategies".
The article was committed and then reachable only by knowing it was
there. It is now named in the opening, in the brief table, and in the
documentation index, because it is the one artefact that argues the
protocol and the measurement as a single claim rather than as two
sections of one repository.
Galmanus added a commit to Galmanus/mirror-pool that referenced this pull request Jul 29, 2026
…fuses our own headline

PR solanabr#5 measures the same channel with brackets, sampling intervals and gates
that refuse unpublishable runs. Ours reported a point estimate. This closes
that gap, and the closure costs us our own number.

crates/riverrun-trace/src/uncertainty.rs (new):
- UnresolvedReason / MemberOutcome: why each member went unresolved
  (no origin within bound, budget exhausted, RPC failure), with is_evidence()
  false for all three, since our walk caps at depth 3 / 14 nodes / SOL-only.
- Census + MAX_FAILURE_RATE 1%: a run whose own infrastructure failed too
  often cannot publish. Previously RPC errors were a printed WARNING above
  the number, which is exactly what gets dropped when the number is quoted.
- Bracket: effective-k with unresolved members merged (favourable) and split
  into singletons (adversarial), both exact, so the unresolved fraction is a
  span, not an assumption.
- effective_k_interval: deterministic bootstrap (SplitMix64, seed
  0x726976657272756e, 10,000 replicates), reporting resampling bias beside
  the range rather than hiding a heavy tail inside it.
- The informativeness gate: >=50% resolved and >=8 resolved members, failure
  gate outranking it, refusal carrying the numbers that caused it.

crates/riverrun-trace/src/runs.rs (new): the published Privacy Cash n=30 run
as data, recomputable offline (). A test proves the histogram
transcription is forced by the counts, not chosen.

Measured on our own published run:
  merged 6.45 | split 1.00 | bracket 1.00…6.45
  95% resampling range 4.90…13.31 (bias +1.79)
  gate: REFUSED - 11 of 30 members (37%) resolved, under the 50% floor

README and docs/EFFECTIVE_K.md now lead with 1.0…6.5 and the refusal, credit
PR solanabr#5's rho=0.0955 measurement by name, and state the metric disanalogy
(rho is k-independent and comparable across pools; effective-k is a member
count and is not). Also documents the uncorrected plug-in entropy bias:
every effective-k we report is plausibly optimistic.

Not done, named: the n=30 run's per-member chains were never written to disk,
so runs.rs recomputes arithmetic, not tracing. Closing that needs a --dump
flag plus a live re-run.

143 workspace tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GrofpSFKdANkYMF52fDu3
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