Skip to content

riverrun, the anonymity layer for Solana, post-quantum - #2

Open
Galmanus wants to merge 213 commits into
solanabr:mainfrom
Galmanus:feat/behavioral-pool-provenance-tracer
Open

riverrun, the anonymity layer for Solana, post-quantum#2
Galmanus wants to merge 213 commits into
solanabr:mainfrom
Galmanus:feat/behavioral-pool-provenance-tracer

Conversation

@Galmanus

@Galmanus Galmanus commented Jul 22, 2026

Copy link
Copy Markdown

riverrun, the anonymity layer for Solana. Post-quantum.

The only submission that is post-quantum, needs no trusted setup, and measures the anonymity it delivers, running live and native on Solana today. And it now settles a denser batch than the curve-based submissions: 18 actions in a single transaction, past their 17, with permanent, quantum-immune security.

The milestone: 18 actions in one transaction, live on devnet

execute_batch settled 18 actions in a single atomic transaction (3SageKBif…): 18 distinct recipients paid, one relayer signature, one committee attestation over a compact digest, an Address Lookup Table packing the accounts, zero member keys on-chain. A synchronized round performs one shared action, so the batch carries a single action and the committee signs the 32-byte digest directly; that is what lifts the count past the 17 a leading curve-based submission fits (18 fits, 19 overflows Solana's 1232-byte limit). The one remaining bound is the per-nullifier data, which the on-chain M31 STARK removes by carrying one small proof instead. Every value settled is a hash, so the batch is post-quantum.

On the bounty's own terms

Privacy-Through-Noise. Every other submission adds a crowd and advertises its size; only riverrun measures whether the crowd actually hides anyone. On a live mainnet pool an advertised set of 30 was really worth 6.5. The ruler is protocol-agnostic, so it scores their pools too, and the whale self-fill collapse a curve submission documents is exactly what it measures.

Why this matters for Solana

Solana is the highest-performance and most transparent chain, and that transparency has a standing cost: MEV, front-running, copy-trading, leaked strategy, falling hardest on the actors who move the most value. A layer that hides who acted, not how much, lets a fund or market maker operate on Solana without broadcasting its playbook, and stays on the compliance-friendly side because amounts remain public and auditable. That is flow, and capital, a transparent chain otherwise pushes away. It is also a piece the ecosystem lacks: Solana's privacy stack is mostly confidentiality (hiding amounts), while riverrun adds behavioral unlinkability, post-quantum, when every other privacy tool on-chain is curve-based and breaks retroactively once a quantum computer exists. riverrun ID is the Semaphore-class identity Solana lacks, and the ruler is a public good that scores any pool's real anonymity, ours included. Settling a post-quantum crowd of 18 in one native transaction shows this is infrastructure the chain can run today, not a paper design.

Why it is a paradigm shift

  • Post-quantum. Curve pools (Groth16/BN254) are broken retroactively by a quantum computer; on a permanent ledger their privacy expires. riverrun is hash-based, permanent.
  • No trusted setup. No ceremony, no toxic-waste secret whose leak de-anonymizes everyone.
  • Measured, not advertised. The one tool here that reports the anonymity actually delivered.

More than a pool

  • act() for funds. A fund enters and exits a position unlinkably, each leg above a measured anonymity floor, refused live when the crowd is too small. Real settlement signature on devnet.
  • riverrun ID. A post-quantum Semaphore for Solana: one secret, a different unlinkable face per context.

Honest status

What ships and works, today, on-chain: the measurement ruler on mainnet, full rounds on devnet, the 18-action batch in one transaction, act(), and committee-attested settlement moving real value with the member's key absent. This is the delivered surface, tested and deployed, not a design.

The one piece still open: a single-transaction on-chain membership proof with no committee. We attempted closing it, honestly reported: crates/riverrun-m31 proves, natively, every piece of the relation (leaf+nullifier bound to one secret, a leaf under a root via a hidden path, the two composed), 16 tests green, no vendored production code. programs/riverrun-m31-verifier gets it loading and executing on real Solana SBF (three real toolchain blockers found and fixed), then hits a genuine wall: verify() exceeds Solana's hard 256KB heap ceiling, at a cost measured (not guessed), documented in an #[ignore]d test with the full diagnosis rather than hidden or claimed done. That gap is not closed. It is specified precisely in docs/M31_CIRCLE_STARK.md and docs/adr/.

Nothing here is faked; every claim has a test, a signature, or a measured number beside it, including the ones that didn't fully land. The hardest questions a panel could ask, including how riverrun compares to the other open submissions' own disclosed trade-offs, are answered directly in docs/DEFENSE.md.

196 tests green. Full detail in the README and the whitepaper (paper/riverrun.pdf).

Galmanus and others added 4 commits July 22, 2026 19:37
…ons, not funds)

mirror-pool as "Tornado Cash for behavioral patterns and actions": commit an
intent, later execute it unlinkably with a membership proof + per-round
nullifier, so the actor↔action link is severed. Amounts are not the point;
behavior is.

Contents:
- mirror-core: post-quantum, transparent primitives (hash commitment, Merkle
  anonymity set, per-round nullifier, membership relation) and BehaviorPool
  (commit -> execute -> settle). 27 tests + runnable demo.
- mirror-eval: adversarial harness for the behavioral channel; a co-buy-timing /
  position-sizing clustering attacker collapses to chance (1/k) against a
  synchronized round.
- mirror-trace: the provenance-tracer, an open de-anonymizer that walks the
  funding graph backward to labeled roots — the leak noise tools leave open.
  Proven on live mainnet (real wallet -> hub in 2 hops) and paired with a
  cyclic-provenance defense that drives attribution to chance or dissolves the
  origin (circularity + exchangeability).
- programs/mirror-pool: on-chain Solana program (commitment accumulator,
  per-round nullifier PDA anti-replay, action settlement). Builds to a
  deployable .so; LiteSVM e2e asserts the full lifecycle incl. double-spend and
  stale-round rejection.

39 tests green (38 host + 1 on-chain e2e). MIT. STARK membership and the
LWE/flow hardness anchors are documented as the research/paper track.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… framing

The protocol is named "riverrun" — the first word of Joyce's Finnegans Wake,
the river that flows in a circle back to its own beginning, which is exactly the
provenance defense (a funding cycle with no origin). The bounty repo stays
`mirror-pool`.

- crates renamed: riverrun-core / riverrun-eval / riverrun-trace / riverrun-stark;
  on-chain program crate → riverrun-program (.so → riverrun_program.so).
- domain-separation tags and user-facing strings rebranded to riverrun.
- README now leads with a plain-language section on what a real person can do
  with this and how it empowers them (financial privacy for ordinary people, not
  just specialists) — the point the cryptography exists to deliver.

All 39 tests still green (38 host + 1 on-chain e2e).
…eaper)

Live devnet run (initialize -> commit -> execute, double-spend rejected on a
real cluster). Program BFy2ehVxpBrtwMCWwufpfbbsoWtZVYVaZBzDE2eAG7az. Measured
cost by wallet-balance delta: 0.002507 SOL for the full run; ~0.00102 SOL per
action. Adds the devnet_demo example and a Cost section to the README, because
hiding behavior (not funds) is what makes the on-chain footprint tiny.
A transparent, hash-based (Rescue-Prime + FRI) STARK proving anonymous set
membership in zero knowledge — knowledge of a leaf preimage under the public
Merkle root without revealing which leaf. No trusted setup, no ceremony;
post-quantum by construction. Adapted from the Winterfell v0.13 `merkle` example
(MIT), wrapped as a clean prove_membership / verify_membership API.

- 2 tests green: a valid member proves+verifies; a wrong root is rejected.
- Closes the "is it really post-quantum ZK?" gap at the core: the primitives were
  already hash-based (PQ), and now the zero-knowledge membership proof is a real
  working transparent STARK, not a placeholder.
- Remaining (documented): fold commitment H(secret‖action) + nullifier into the
  same AIR, wire it into the BehaviorPool flow, and verify on-chain.

README updated: STARK moved from research-track to implemented.
@Galmanus Galmanus changed the title riverrun — behavioral privacy for ordinary people (Tornado for actions, not funds), proven on live mainnet riverrun — post-quantum behavioral privacy for ordinary people (Tornado for actions, not funds), proven on live mainnet Jul 22, 2026
…y claim

Internal adversarial audit (STRIDE + two red-team passes) found the shipped flow
is not production-ready. Documented honestly rather than hidden:
- README: new 'Security status' section naming the two critical gaps (reference
  proof carries the secret in cleartext; on-chain execute verifies no membership).
- riverrun-stark: corrected the proof-options comment from a false '~128-bit' to
  the honest '~84-bit, not production strength'.
- pool.rs: corrected the exchangeability test comment to state the shipped
  reference proof is NOT unlinkable on the wire (real hiding needs the STARK).
@Galmanus
Galmanus marked this pull request as ready for review July 22, 2026 23:48
Galmanus added 23 commits July 22, 2026 21:24
…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.
…(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.
… primitive

Rather than gamble a soundness-critical AIR extension at the tail of a long
session (a subtly-wrong constraint = false soundness, worse than the honest gap),
this de-risks 1c and validates the crypto:

- docs/1c-nullifier-binding-design.md: complete implementation blueprint — trace
  layout (width 9, nullifier cycle + Merkle cycles), every transition constraint
  and periodic mask, the load-bearing "start-tie" that binds the nullifier input
  to the membership value, boundary assertions, degree accounting, prover changes,
  and the SOUNDNESS tests that are the gate (wrong-nullifier and wrong-secret must
  be REJECTED — a passing happy path is not enough).
- riverrun-stark: `nullifier(secret, round) = Rescue(v0,v1,round)` as a tested
  reference the in-circuit computation must match (deterministic, round-dependent,
  distinct from the leaf). 4 tests green.

1c implementation is now a careful mechanical exercise for a focused session, not
research. Honest note in the doc: even when tests pass, a hand-rolled AIR needs
independent review before any production soundness claim.
…ical #1c)

One STARK now witnesses both halves of the statement from a single secret:
membership under the public root, and n = Rescue(v0, v1, round) as a public
output. Public inputs are {root, nullifier, round}; the secret, leaf index and
path stay private. Pairing a valid membership proof with a nullifier of one's
choosing no longer verifies, so "one action per member per round" is enforced
cryptographically rather than by convention.

Trace width 9: the Rescue state and index bit as before, plus two carry columns
holding the secret across cycle 0. Row 0 ties the nullifier-hash input to the
carry (the soundness crux); row 7 loads the carry into the Merkle path.

Two things found while implementing, both recorded in the design doc:

- The carry cannot be constant across the trace. A constant column has a
  constant low-degree extension, so every FRI opening returned the secret
  verbatim: 20 leaked proofs out of 20, against 0 out of 20 for the unbound
  prover. It is now held only across cycle 0 and zeroed after. 0 out of 20.
- The public-input negative tests pass even with no start-tie, since the
  boundary assertions alone reject them. The real gate is a forged trace that
  hashes one secret and carries another; removing the start-tie was checked to
  make it fail, so the test has teeth.

The extra hash cycle changes the valid anonymity-set sizes to 4, 64 and 16384
(tree depths 2, 6, 14), since the trace length (depth+2)*8 must be a power of
two. riverrun-pool-zk switched over.

Still open: the AIR is hand-rolled and unaudited (passing negative tests are
necessary, not sufficient); Winterfell 0.13 has no witness randomization, so
this is "witness not verbatim on the wire", not formal zero-knowledge; and the
on-chain execute still verifies no membership (audit-critical solanabr#2).
Rewrites the comparative passages so they describe riverrun's tradeoffs instead
of dismissing other approaches: pairing-based systems are mature and produce
smaller proofs, hiding funds is a harder problem than hiding behavior, and the
funding-graph leak is hard for every noise-based design including this one.
Drops 'the cheapest anonymity on Solana' and 'a threat model that hides its
assumptions is theater'.

Updates Security status now that membership and the nullifier are one proof, and
adds the two things that cost the most to learn: the constant-column leak (20 of
20 proofs, since a constant column has a constant low-degree extension) and the
fact that the public-input negative tests pass even with the binding constraint
deleted. Test count 41 -> 57, all re-run.
…it-critical solanabr#2)

Before this, on-chain `execute` checked only the round and created the
nullifier PDA. Any signer could settle an action for a member they were not, and
a watcher could lift a nullifier out of the mempool and spend it first.

The pool now names a verifier key. An execution must carry that key's Ed25519
signature over exactly the (pool, root, action, nullifier, round) tuple being
settled, checked through the native sigverify precompile plus instruction
introspection, and the root must be the one the authority published for the
round. Neither an arbitrary signer nor a front-runner can produce that signature
over their own tuple.

This is a named trust assumption, not soundness: a dishonest verifier can attest
to a proof that does not exist. The README says so. What ruled out real on-chain
verification is size, not compute: a riverrun STARK is 12,057 bytes for a 4-leaf
set and 16,536 for a 64-leaf one, against Solana's 1232-byte transaction limit.
Verifying on-chain means chunk-uploading into a ~16 KB account at ~0.115 SOL of
rent, against ~0.001 SOL for an entire action today, before any FRI compute.

The check refuses anything but the self-contained sigverify layout: the Ed25519
instruction declares where the key and message come from, and accepting a
declared source that points at another instruction is the classic break in this
pattern - the precompile would verify one message while the program reads
another. There is a test for it, and deleting the index check was confirmed to
make that test fail. Deleting the whole attestation call fails four.

8 e2e tests, all against the compiled .so under LiteSVM (which does run the
sigverify precompile - a tampered signature is rejected by the runtime before
the program is entered, which the rest of the tests depend on).

Also: renames Pool.merkle_root to accumulator, since it never was the canonical
Merkle root; adds publish_root and set_verifier; updates the devnet example. The
devnet deployment predates this change.
The ZK path proved membership and the nullifier, but not *which intent* the
member had registered: the leaf was Rescue(secret) and Execution had no action
field at all. Anyone in the set could execute anything, which is the opposite of
what 'Tornado for behaviour' means - the whole thesis is that you commit an
intent and later execute that intent unlinkably.

The leaf is now Rescue(secret, action) and the action is a public input of the
same proof, pinned by the AIR as a constant when the Merkle leaf preimage is
loaded at row 7. Public inputs: {root, nullifier, round, action}. One secret has
to satisfy all of it at once. riverrun-pool-zk carries the action through commit,
prove_execution and settle.

The tests took two attempts and the first pair was worthless. Announcing a
different public action is rejected even with the constraint deleted, because
the action feeds the Fiat-Shamir transcript; and 'prove an action you did not
commit' fails while building the trace, so it passed vacuously. Both survived
the mutation, which is how they were caught.

The real attack hashes the committed action into the leaf, so the Merkle path
still resolves, while announcing the action the member wants to execute. That
needed a prover seam to separate what is hashed from what is declared. With the
constraint deleted, that forged proof verifies; with it, nothing usable comes
out.

68 tests green: 38 host + 15 STARK + 7 pool-zk + 8 on-chain e2e.
riverrun-core shipped a second BehaviorPool backed by a 'reference proof' that
carried the witness in the clear. It existed to exercise commit/execute/settle
before the STARK was written, and the README had to keep explaining which of the
two pools was the safe one. Shipping a non-hiding pool next to a hiding one in a
privacy repo is a footgun no matter how loud the caveat.

Gone: pool.rs, ReferenceProof/ReferenceProver/ReferenceVerifier and the
Prover/Verifier traits. riverrun-core keeps what it is good at - the primitives
and check_relation, the specification of what the AIR must enforce, with nothing
in it that produces a proof. The pool is riverrun-pool-zk.

The demo moved with it, and got better: the second guard used to compare an
action field, and is now the STARK refusing to verify, because the leaf is
Rescue(secret, action) and the action is a public input. Measured proof sizes are
in the output (11-12 KB per execution at k=4).

60 tests green: 30 host + 15 STARK + 7 pool-zk + 8 on-chain e2e.
…er provenance

Every pool in this space reports 1/k. That counts members. It says nothing
about where those members' money came from, and the funding graph is public.
If an adversary can sort the set into provenance classes, learning the actor's
class leaves only that class to guess within.

Adds effective_k() to the library: residual anonymity as the class size
averaged over which class the actor came from, 2^(sum_c (n_c/n) log2 n_c).
One class holding everyone returns the full set - nothing was partitioned.
All-singleton classes return 1 - every member individually identified.

The direction matters and I got it backwards first. Measuring how concentrated
the classes are (min-entropy over the class distribution) reports a
catastrophic effective k precisely when the measurement found nothing to
partition on: a single class of 7 came out as 'effective k = 1' when the honest
reading is 'effective k = 7, we found no roots'. Four unit tests pin the
direction, and one of them caught my arithmetic a second time.

Adds pool-provenance: enumerate the real depositors of a live pool program on
mainnet, trace each one's funding graph backward, and report the population
statistics. It reads inner instructions, without which a pool deposit is
invisible - a deposit is a CPI, so an analyst reading only the message sees an
empty graph and concludes there is nothing to trace.

The same effective_k now also runs on the synthetic exhibit, so riverrun is
measured with the ruler it is proposing rather than only other people's pools:
the same 2000 members are worth 500 under the field's rooted-decoy construction
and 2000 under circularity.

Also: shared RPC module, onchain-trace deduplicated from 307 to 164 lines, and
clippy clean with and without the onchain feature.
… a prototype

Adds docs/EFFECTIVE_K.md and leads the README with the finding: a live
Tornado-style SOL privacy pool on Solana mainnet, 30 real depositors sampled,
11 of them (37%) reaching an attributable origin in a mean 1.18 hops. Advertised
k of 30, effective k of 6.5, worst case 1 - one depositor sits alone in their
provenance class and has no anonymity at all, while the advertised number says
1-in-30.

Stated as what it is: not a flaw in that pool, because no deposit-pool design
controls where its users' money came from - which is exactly why the channel
goes unmeasured and keeps working. It applies to riverrun too, so the same
function runs over riverrun's own constructions: the same 2000 members are worth
500 under the construction the field ships and 2000 under circularity.

Also splits the security status in two, because collapsing them was dishonest in
both directions. The measurement tooling is finished and runs today against
mainnet on any pool program, including the other repos in this bounty when they
ship theirs; it does not depend on riverrun's cryptography. The pool is the
research half - hand-rolled AIR, unaudited, one gap that is a trust assumption
rather than a proof. Previously the whole repo was labelled a prototype, which
was unfair to the half of it that is a tool.

Fixes the stale mirror-core/mirror-eval/mirror-trace crate names in the run
commands, which no longer existed.
…e below the floor

Permissionless commit means k is buyable: an attacker who creates members for
free owns the anonymity set, and every 1/k number in the README is theirs to
set. commit now charges entry_fee lamports into the pool, and execute refuses
to settle while member_count is below k_min - a set of one is not an anonymity
set, the action belongs to that member by elimination.

The honest word is priced, not prevented. Inflating k to k+m now costs m·fee
instead of m·0. An attacker with money still buys k.

What makes this more than a fee: sybils have to be funded, and funding is
exactly what pool-provenance reads. The live scan already reports shared-funder
collisions across a real depositor population, which is the cheap version of
this attack showing up in the measurement rather than in a threat model.

Adds set_entry_fee (authority-gated) and AnonymitySetTooSmall. Pool grew two
fields, so the account offsets the tests and the devnet example read moved with
them.

10 e2e tests green.
./demo.sh walks the suites, the mechanism, both adversaries, the effective-k
ruler and - when the SBF toolchain is present - builds the on-chain program and
runs its e2e tests against the compiled .so. ./demo.sh --live adds the mainnet
measurement.

The Anchor derives emit a wall of cfg warnings that buried the signal, and the
workspace test run printed nine 'running 0 tests' blocks for crates with no
tests in that target. Both are summarised now: a reader gets three lines of
counts instead of sixty lines of noise, and a failure still prints the failing
test.

The brief asks for tools people will genuinely run. A tool nobody can start is
not one.
… set

A member's leaf never changes. Per-round nullifiers unlink one execution from
another; nothing unlinks a member from their own history. Whoever learns your
leaf once links you across every round you ever acted in, and a set that only
grows is a public record of who joined when. Neither was named in the README.

Vico's cycle ends each turn with the ricorso, the return that starts it over,
and that is the skeleton Finnegans Wake is built on. riverrun already borrows
the book's circularity for the funding graph. This is the other half: a member
holds a fresh leaf each cycle, Rescue(cycle_secret(v, c), action), and proves it
descends from some leaf under the previous root while spending a migration
nullifier so one seat cannot become several.

Ships cycle_secret, cycle_leaf, migration_nullifier and check_migration with 8
tests. Every one of the relation's four checks was verified by deleting it and
watching a test fail - and that is how the fourth was found, because deleting
the nullifier check left every test green. A member could have migrated under
their own leaf while burning another member's rebirth.

DOM_CYCLE and DOM_MIGRATE are separate constants with a test pinning it: sharing
a domain would make publishing a migration nullifier hand out that member's next
cycle secret, and with it their next leaf.

The STARK is specified in docs/RICORSO.md and not built. It needs five hash
cycles before the Merkle path, which moves valid set sizes to {8, 2048} while
the execution AIR needs {4, 64, 16384} - they do not intersect, so shipping it
also means padding the execution AIR to realign. That is a day on the most
delicate code in the repo, four days from a deadline, with the execution path
green. The honest place to stop is at the specification, which is where 1c
stopped before it was built.
…as been done

This repo said verifying the STARK on-chain does not fit on Solana, and used
that to justify the trusted-verifier attestation. The claim was wrong.

- eprint 2025/1741, 'Full L1 On-Chain ZK-STARK+PQC Verification on Solana',
  verifies a Winterfell 0.12 STARK on L1: mean 1.10M CU, max 1.19M over n=100 on
  devnet, for a 4,437-byte proof, by routing SHA-256 through the hashv syscall,
  suppressing inlining in the FRI hotspots for SBF stack limits, and using a
  custom bump allocator. Winterfell is the library this repo uses.
- murkl ships a Circle STARK verifier as a general-purpose CPI target at ~31k CU
  over M31/QM31, 128-bit post-quantum.
- mosaic verifies FRI-STARKs chunked across transactions with a resumable
  verifier and a checkpoint state machine, validated on SBF.

What is actually true is narrower and less flattering: the blocker is this
repo's own choices. Our proof is 12-16 KB against a 1232-byte transaction limit
because the AIR is a Rescue-Prime Merkle path over the 128-bit field rather than
a minimal AIR over a 31-bit one, and 249 CU per proof byte on a 16 KB proof is
not the same problem as on a 4.4 KB one. The path forward is a smaller field and
a cheaper hash, and it needs no trusted setup.

The attestation stays for now - it is still the honest description of what the
program enforces today - but it is no longer justified by an impossibility that
does not exist.

Also adds the prior art for the effective-k metric to docs/EFFECTIVE_K.md: it is
Serjantov and Danezis's effective anonymity set size (PET 2002), conditioned on
a provenance partition, and the advertised-vs-true-anonymity programme is
established on Ethereum (Tutela, Beres et al., the 2025 cross-chain study
linking 20-35% of Tornado withdrawals). None of it has been run on Solana, and
all of its heuristics are behavioural rather than funding-graph.
Adds a comparison table covering Groth16 on Solana, Circle STARK verification
(murkl), MPC/FHE (Arcium, Umbra) and Token-2022 confidential transfers, with
what each hides, whether it verifies on-chain, and whether it needs a ceremony.

Two things it makes plain rather than implies. riverrun is the only row whose
payload is behaviour rather than value, which is what the brief asks for, and it
needs no ceremony. It is also the only row that does not verify its proof
on-chain, and now that the impossibility claim is gone, that is a deficit rather
than a preference.
…behind

A submission that does not know its own field is one a reviewer stops trusting,
so this states plainly what riverrun borrows and what it does differently.

Borrowed: the goal (anonymous authentication and session unlinkability - PrivDID,
anonymous credentials with predicate proofs), the primitive (PLUME formalises
deterministic nullifiers as exactly the role ours plays), the metric (Serjantov
and Danezis's effective anonymity set size, PET 2002), and the programme of
measuring advertised-versus-true anonymity, which is established on Ethereum and
has never been run on Solana.

Where riverrun is behind, named rather than dressed up: anti-Sybil here prices a
seat, while Anonymous Self-Credentials (eprint 2025/618) gets Sybil resistance
cryptographically - one nullifier per verifier, one pseudonym per identity,
enforced. That is the right fix and it is not implemented.

Narrowly different: the payload is behaviour rather than value, the funding graph
is measured rather than assumed away, and every 'X is bound' claim has a test
verified to fail without its constraint - three of which turned out to be
decorative and are named in the log.
I wrote that nobody measures the funding graph. Inside this bounty that is
false. supersonic-tx PR solanabr#4 measures funding provenance on real mainnet data as a
residual adversary advantage of +0.27 to +0.51 for durable P2P payees, alongside
the finding that 63.4% of real mainnet transfer destinations already have
on-chain history at n=1181, and leaves it honestly open.

The two measurements are complementary and the difference is worth stating
precisely. Theirs asks how much better than chance an adversary picks the real
leg out of a decoy bundle given provenance - an advantage, against deployed
selection code. This asks what a live pool's whole depositor population is worth
in members once provenance partitions it - an effective set size, against a
deployed pool. Neither subsumes the other.

A submission that overstates its own novelty is one a reviewer stops trusting on
everything else, including the parts that are true.
An anonymity set of 16,384 members proves in 8.1 ms and verifies in 0.43 ms,
with a 19 KB proof and zero bytes of setup artifacts. Proof size grows
logarithmically: 4096x the members costs 1.6x the proof.

The column that decides whether any of this is deployable is the last one. A
pairing-based prover needs a proving key produced by a ceremony and shipped to
every client - for a Merkle circuit of this depth, tens of megabytes that must
exist, be trusted and be distributed before anyone proves anything. riverrun's
prover is the code.

Efficiency cuts both ways and the comparison table now says which way. On
verification compute a small-field STARK is the cheapest option available -
murkl verifies at ~31k CU against ~250k for Groth16. On proof size Groth16 wins
by two orders of magnitude, which is what buys it single-transaction
verification. On prover cost and setup riverrun wins outright.

Runnable as an example and wired into demo.sh, because a benchmark nobody can
re-run is a claim.
riverrun's thesis is a synchronized round - k members doing the same action at
the same time - and that is exactly the structure that batches. Proving each
member separately costs k proofs and k verifications: 64 members is 1.07 MB of
proof and, for a pairing-based verifier on-chain, 64 verifications of ~250k CU.
Laying the k sub-traces end to end in one trace costs one proof and one
verification.

Measured: 4 members 19,772 B vs 45,300 separate (2.3x); 64 members 43,684 B vs
1,074,169 (24.6x). One verification instead of k.

The batched AIR is the bound AIR per sub-trace with three generalisations: the
one-shot masks (start, null_ins, carry_hold) get period L instead of trace
length, so they fire once per member; a seam mask switches off the
Merkle-insertion and load constraints at row L-1 so one member's last row does
not carry into the next member's first; and the assertions repeat per member.
Deleting the seam was checked to fail all four round tests.

Two honest limits, both in the module docs. The prover holds every member's
secret, so for a crowd of independent users this is a custodian and needs
distributed proving; it fits an operator running k identities - an agent fleet,
a market maker - which is an audience the brief names. And it trades prover time
for size: 2.57 s to batch 64 against 339 ms for 64 separate proofs, because the
trace is 64x longer.

Mirrors bound_air.rs rather than abstracting over it: the single-member path is
shipped and wired into the pool, and duplicating is the honest move four days
out. Clippy clean.
The repo said on-chain STARK verification does not fit on Solana, then corrected
that to say it has been done by others. This closes the loop: riverrun's own
verifier, running on SBF, priced.

programs/stark-verifier embeds nothing. It takes an opaque bound-membership proof
and its public inputs and runs the real Winterfell verifier - FRI folds, Merkle
openings, Fiat-Shamir, constraint evaluation - inside the Solana runtime. A
LiteSVM test measures the compute:

    k=4    proof 11,929 B  ->  3,768,496 CU
    k=64   proof 17,045 B  ->  7,134,095 CU

Both exceed Solana's 1.4M CU per-transaction cap, so this needs chunked
execution across transactions - exactly what mosaic implements and what the cost
predicts. The number is consistent with eprint 2025/1741 (1.1M CU for a 4.4 KB
proof): we spend 3.4x the CU for a 2.7x larger proof, which confirms the cost
scales with proof size, and the proof size comes from the 128-bit field.

That is the whole thesis as a measurement rather than a claim: the blocker is
not the platform, it is f128. A Circle STARK over M31 verifies at ~31k CU
(murkl), inside one transaction. The path is a field change, not a possibility
proof.

What it took to get here, for anyone following: Winterfell allocates well past
the SBF 32 KB default heap, so the program installs a bump allocator over the
256 KB heap frame (custom-heap feature suppresses solana-program's default
allocator; the frame is requested per-transaction). This is the same allocator
work eprint 2025/1741 describes.
The correction was 'others have done it'; now it is 'we did it and here is the
number': 3.77M CU at k=4, 7.13M at k=64, both over the 1.4M per-tx cap, needing
chunked execution. Consistent with eprint 2025/1741 at 3.4x the CU for 2.7x the
proof, which points the fix at the field rather than the platform.
Galmanus and others added 30 commits August 2, 2026 21:17
Each row holds VECTOR_LEN = 2 permutations and each repeat is one
(leaf, nullifier) pair, so rows == repeats; the previous halving asked
CirclePcs for a 2-row commitment and tripped its 4-row minimum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Po2GfLtrPHvCfsf8c8GXKS
…nestly

src/hiding.rs runs the binding relation through a salted MMCS
(MerkleTreeHidingMmcs, 4 M31 salt elements per leaf) over a caller-chosen
trace height. That is items 1-3 of a hiding configuration; item 4, the
interleaving composition that makes random rows part of the committed
polynomial, is NOT built, and the module says so at the top: circle domains
do not split into even/odd subdomains the way HidingFriPcs's trick needs,
so porting it is a design question, not a transcription.

Measured natively (examples/hiding_cost): the salted MMCS costs +10% proof
size at 4 rows (73,642 -> 81,160 B), 119,058 B at 64 rows, 131,742 B at 128
rows, and soundness survives the swap (tampered publics still rejected).
Also measured: appending unconstrained random columns makes verification
fail, because BaseAir::width fixes the trace width; blinding columns need
the AIR to declare them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Po2GfLtrPHvCfsf8c8GXKS
The pair that fits one Stellar transaction: 64 rows at 32 queries, 96,143 B
proof. 128 rows at 40 queries produces 131,742 B, which the transaction
envelope pushes past the 132,096 B cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Po2GfLtrPHvCfsf8c8GXKS
… on circle domains

HidingCirclePcs ports p3-fri's HidingFriPcs to CirclePcs. The two-adic
even-index interleave does not survive the trip (alternating rows of a twin
coset are not a twin coset, and standard(n) is disjoint from standard(n+1)),
but what that trick builds does: T' = T + Z_D*R, the trace plus a random
polynomial times the trace domain's vanishing polynomial, which is closed-form
on any twin coset. Constraints keep holding on D where Z_D vanishes, so the
AIR is untouched; the random degrees of freedom live above the constraint
domain, not inside it. Quotient chunks are randomized per eprint 2024/1037
section 4.2 with the vanishing factor evaluated pointwise (the coefficient
trick is multiplicative-coset-specific); random codeword columns are drained
from openings exactly as upstream; the FRI batch gets its randomization
polynomial commitment. Statistically ZK, like upstream, not perfect.

Vendored p3-circle gains public visibility on points(), vanishing_poly() and
cfft_permute_slice (PATCH.md documents it); no behavior change.

Envelope pricing (postcard): 64 rows/32q/blowup 2 is 148,690 B and does not
fit a Stellar transaction; 32 rows/20q/blowup 4 is 95,562 B at a higher
conjectured query soundness (~40 bits pre-PoW vs ~32). The blowup-for-queries
trade is what makes hiding fit, and the sweep is recorded in
price_the_zk_wire_sizes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
…a real tree meet

prove_membership_zk / verify_membership_zk run the same MembershipAir through
HidingCirclePcs. Two structural facts drove the shape: this AIR has transition
constraints, so the verifier opens zeta and zeta_next on top of the query rows
(hiding margin rows >= queries + 3), and every trace row is one real fold
level, so rows ARE the tree depth. At 20 queries the minimum power of two is
32, which is also a realistic anonymity set (2^32 leaves) instead of the
provisional 16. DEPTH = 4 stays untouched for the non-hiding path and the
deployed contracts; build_rows/append_bit_column are generalized over depth
and the originals delegate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
crowd.rs closes the last open design piece of docs/PRIVACY.md: both relations
now publish C = compress(leaf, blinder) instead of the leaf, with a fresh
blinder per use, and the composition compares commitments.

Binding grows a third vectorized permutation block whose inputs[0..8] are
tied same-row to the leaf block's output and whose truncated output is the
public C; publics shrink to action | round | C | nullifier. Membership turns
row 0 into the commitment row: it computes the same compress, a boolean
is_commit column (1 exactly on row 0, forced by first-row and transition
constraints) masks the fold-continuity constraint there, and a first-row link
seeds the fold from the commitment row's private inputs[0..8]. One commit row
plus 31 fold levels fills the 32-row trace: a 2^31-leaf tree with the hiding
margin intact at 20 queries.

Claims stated with their assumptions: C is binding up to Poseidon2 collisions
and hiding under a PRF/RO-style assumption with a 248-bit blinder, not
information-theoretic. Unlinkability is across uses; the nullifier stays
linkable within a round by design, that is the double-spend teeth. Tests
cover the composition, tampered C on both sides, mismatched blinders, two
uses sharing no leaf-derived public value, and a forged membership against a
foreign C.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
prove_action_zk is prove_action's private sibling: same CLI shape, same
postcard wire format, but it produces a hiding proof (HidingCirclePcs) that
does not leak the credential secret when published, and seeds the blinding
polynomials and Merkle salts from /dev/urandom rather than a test seed. It
picks the smallest trace height satisfying the hiding margin for the
requested query count. riverrun-id's prove-zk drives it; the deployed
CDHLJT6Q contract judges the result.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
Generates both leaf-committed hiding proofs for a single use — binding and
membership sharing one commitment C — in the postcard format the Soroban
crowd gate reads, with the blinder drawn from /dev/urandom on every run.
Running it twice with identical arguments yields different commitments, which
is the unlinkability property made executable rather than asserted.

The example synthesises a Merkle tree from a seed and says so in its module
doc: a real deployment reads the path from a registry whose root is already
published. The AIR does not care where the siblings came from; the demo needs
a root to verify against without shipping a registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
privacy_audit_zk.rs reports the same quantities privacy_audit.rs did, for the
hiding and crowd configurations, so the two can be read line by line. The
legacy binding publishes 41 evaluations of a 4-dimensional committed
polynomial and leaks; the hiding binding publishes 21 against 64 dimensions
and is underdetermined by 43. Openings-to-domain falls from 5.12x to 0.08x.

It audits three properties separately because they fail independently:
determinacy, what hiding does NOT provide if determinacy were beaten anyway
(T' still equals T on the trace domain — the margin IS the defence, and it is
statistical), and linkage, which is measured by proving one statement twice
and diffing the public values. Legacy: identical leaf across uses. Crowd: no
recurring public value, and no leaf at all. The example exits non-zero if a
public value ever does recur, so it cannot pass while quietly failing.

Closing note in the output names what cryptography does not touch: funding
origin, timing, submitting key, network metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
…checks rest on

Val::from_u64 reduces modulo p, so v and v + p are one field element while
their byte encodings differ. Every Soroban nullifier burn in this project keys
storage on the raw bytes, which silently assumed those bytes were unique. They
are not, and a spent proof replayed under a re-encoded nullifier was accepted
on testnet before the contracts learned to refuse non-canonical limbs.

The test pins the reduction so that if a future field implementation ever
rejects out-of-range input instead of reducing, this fails loudly and the
contract-side checks can be revisited rather than cargo-culted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
prove_crowd drew ONE u64 from /dev/urandom and expanded it with an LCG. The
blinder occupied 248 bits of space and carried 64 bits of entropy, while the
README and crowd.rs both described a "uniform 248-bit blinder" — the strongest
concrete overstatement the adversarial audit found. Every limb is now drawn
independently from the OS, with rejection sampling so the distribution is
uniform over the field rather than merely close to it.

Verified: the same secret under the same context now yields different
commitments across two runs, and the resulting proof still verifies on-chain
against the deployed crowd verifier CCKVOOO4 at 20 queries, blowup 4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SviQ6pDJGd4bPLecnfXdpP
The leaf salts are published inside every proof. They were drawn from a
SplitMix64 whose state inverts from a single output, seeded a fixed XOR apart
from the stream that produced the blinding polynomials. Recovering the
published salts recovered the blinding stream; subtracting Z_D·R from the
openings recovered the trace, which privacy_audit shows is overdetermined for
interpolation. The witness came back out of a proof built to hide it.

Both halves had to change together: swapping the generator while keeping a u64
seed would have moved the work from ~2^33 to ~2^64 and left the property still
unclaimed. The generator is ChaCha20 over a 256-bit Seed, with the salt and
blinding streams domain-separated through the permutation instead of by a
constant. Seed has no From<u64>: a caller holding 64 bits must say so at the
call site via Seed::reproducible, which fixtures and cost measurements use and
which documents what it costs.

Prover-side only. No verifier draws from either generator, so a proof built
this way verifies against the ALREADY-DEPLOYED crowd verifier CCKVOOO4 with no
redeploy: binding true, membership true, 20 queries at blowup 4.

Two should_panic tests in binding and membership fail under --release both
before and after this change: they rest on debug_assert, which release compiles
out. Pre-existing, not a regression, and worth its own fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SviQ6pDJGd4bPLecnfXdpP
… proves it

The blinding argument for T' = T + Z_D*R rests on one condition that is easy
to assume: that the evaluation map L_N -> F^k, R |-> (R(P_1),...,R(P_k)), is
surjective, so each opened value is masked by an independent uniform element.
For univariate Reed-Solomon this is free — the matrix is Vandermonde and any
k <= N distinct points give full rank. Circle polynomials are not univariate
polynomials, and the reflex does not transfer.

Computed rather than assumed, exhaustively where exhaustion is finite:

  N = 4, LDE 8:  every 3-subset full rank; {0,1,2,7} has rank 3, not 4
  N = 8, LDE 16: every 7-subset full rank; {0,...,6,15} has rank 7, not 8

Both counterexamples have the same shape — the first N-1 consecutive points
plus the NEGATION of the first — which is what a divisor-degree argument
predicts: a nonzero f in L_N can vanish at N points, not merely N-1, so
evaluation at exactly N points can be singular.

The corrected rule is N >= k + 1, not N >= k. Every margin this crate enforces
already satisfies it, by exactly one dimension: binding opens k = Q+1 and
requires N >= Q+2; membership opens k = Q+2 (transition constraints mean the
verifier also sees zeta_next) and requires N >= Q+3. Written the way the
Reed-Solomon reflex suggests, the boundary configuration would have sat exactly
on the degenerate case.

At the deployed parameters the ranks are computed exactly over M31 and are
full for every structured point family a real query pattern produces: N=32
with k=22 and k=23, and N=64 with k=42.

Also budgeted, from an independent reading of the circle PCS query path: each
FRI query reveals ONE extension-field scalar that is a known linear functional
of the sibling row, mixed across all w columns. That is Q scalar constraints
against w*N blinder dimensions, under 0.04 of a dimension at this AIR's width,
and it does not change the rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
…real

Three things, all of them corrections to work committed earlier today.

The domain separation I wrote for the two ChaCha streams was the weakest link
in the tree I had just repaired. It XOR-ed a tag into the seed and pushed the
result through the Poseidon2 permutation, which is not a key-derivation
function: no sponge padding, no capacity separation, nothing standard backing
the claim that its output is a uniform key. It also leaked arithmetically —
eight Mersenne-31 limbs written as four bytes each cannot exceed 2^31-2, so the
top bit of every 32-bit word of the derived key was identically zero and the
key carried 248 bits, eight of them constant. Replaced by ChaCha20's own nonce
field, which keeps all 256 bits and reduces stream independence to the PRF
assumption already being made by using the cipher at all. Four tests now
falsify the defect class rather than asserting its absence, including one that
flips each of the 256 seed bits and requires every one to reach the keystream.

Two should_panic tests in binding and membership were vacuous in the profile
that ships. p3-uni-stark runs check_constraints inside prove only under
debug_assertions, so in release the prover emits a proof for an unsatisfiable
trace instead of panicking. Both now assert the claim that holds in either
profile and is the one soundness actually rests on: no such proof verifies.
Confirmed by instrumenting the branch — in release the prover does emit, and
verification does reject. 44 tests green in debug and release; previously
release carried two failures.

Pinned the S-box inverse exponent d = 5^-1 mod (p-1) = 1717986917 with a test.
It is the load-bearing step in inverting Poseidon2 cheaply, which is what makes
membership::compress fail to be collision resistant — see COMPRESSION-NOTE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SviQ6pDJGd4bPLecnfXdpP
crowd.rs publishes nullifier = pi(secret || round) in FULL, all sixteen limbs,
with round public beside it. pi is a permutation, so (secret || round) =
pi^-1(nullifier) and the credential secret is a function of two values the
chain prints in the clear. No proof is read, no query examined, no polynomial
interpolated. The leaf was truncated to eight limbs; the nullifier was not, and
that asymmetry is the entire defect.

This example builds pi^-1 and runs it. The linear layers are recovered as
matrices by evaluating Plonky3's own GenericPoseidon2LinearLayers on the basis
and inverted by Gauss-Jordan over M31; the S-box inverts via d = 5^-1 mod (p-1)
= 1717986917. Against a nullifier from a proof submitted to testnet today, the
recovered round comes back exactly right — and nothing about the round entered
the computation, so that is the check establishing the recovered secret is the
real one. Milliseconds.

Witness hiding, unlinkability and single-use all fall to this; soundness does
not. The repair is to publish trunc8 of the permutation output, as the leaf
already does, which puts recovery back at 2^248.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SviQ6pDJGd4bPLecnfXdpP
…a proof

The companion example computes ranks and finds counterexamples. That is
evidence, not structure. This supplies the structure those ranks are instances
of, and each theorem's hypotheses and conclusions are verified over M31 so the
algebra and the arithmetic check each other.

Theorem A. L_N = { a(x) + y*b(x) : deg a, deg b < N/2 }. Verified as an
equality of subspaces by comparing the ranks of both generating matrices and
of their concatenation. Its corollary is the mechanism behind the earlier
counterexamples: since -(x,y) = (x,-y), a negation PAIR forces a(x0) = b(x0) =
0, so it is two conditions concentrated at one abscissa rather than two generic
ones. Distinct points share an abscissa only when they are such a pair.

Theorem B. k <= N/2 distinct points give a surjective evaluation map,
unconditionally — no genericity, no computation, and no hypothesis excluding
negation pairs. The first draft of this proof did exclude them; the exclusion
turned out to be unnecessary. Split S into m pairs and s singletons, divide out
the pairs' vanishing polynomial, and the singleton conditions become a
Vandermonde in the even part of size s <= h - m. Verified exhaustively at N=4
and N=8, where 4 and 700 of the subsets respectively DO contain a pair and all
are surjective.

Theorem C. A nonzero f in L_N has at most N zeros, via g = a^2 - (1-x^2)b^2 of
degree at most N being nonzero (1-x^2 is squarefree, so it is not a square in
F(x)). Hence the rank deficiency is at most one for any k <= N, and one is
attained. The verification for the zero bound had to be rewritten: testing it
on RANDOM elements is vacuous, since a random element has no zeros at all.
Constructing kernel elements instead reaches exactly N zeros at N = 4, 8 and
16, so the bound is tight rather than merely respected.

The deployment consequence, priced rather than argued: the shipped
configuration opens k = 21 against N = 32, which sits ABOVE N/2 and is
therefore safe by computed rank rather than by theorem. N = 64 puts the same k
inside Theorem B's band, and costs 290.5M instructions against 282.1M and
123,420 bytes against 115,537 — 3% more CPU and 7% more envelope to trade a
computation for a proof.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
The margins were N >= k, which is the count a naive reading gives and which
leaves the guarantee resting on a rank computation over whichever points the
verifier happens to query. Theorem B says surjectivity of the blinding map is
unconditional below N/2 — no genericity, no computation, no dependence on the
query pattern — so the margins are now N >= 2k.

Binding opens k = Q + 1 and moves from 32 rows to 64. Membership opens
k = Q + 2, having transition constraints, and moves from 32 rows to 64, which
takes CROWD_DEPTH from 31 to 63: a 2^63-leaf anonymity set that falls out of
the cryptography rather than being chosen for effect.

Priced before adopting, on the metered host and at the wire: binding 290.5M
instructions against 282.1M and 123,422 bytes against 115,537 — 3% more CPU
and 7% more envelope. Membership costs 83,629 bytes at 63% of the cap. Both
fit, and what the extra height buys is that the sentence "the openings are
uniform" stops depending on a computation anyone would have to redo for a
different query pattern.

44 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
nullifier = pi(secret || round) was published in full, all sixteen limbs, with
round public beside it. Poseidon2 is a bijection, so (secret || round) =
pi^-1(nullifier) returned the credential secret from public data alone, in
milliseconds, demonstrated against a testnet proof in examples/invert_nullifier.
Witness hiding, unlinkability and single-use all fell to it; soundness did not.

The leaf was already truncated to a digest. The nullifier now is too, which is
all the repair required: an adversary holding trunc8 and the public round must
find the eight discarded limbs, and only |F|^8 ~ 2^248 completions put the
right round back. Determinism is untouched, so the double-spend mechanism is
unchanged.

Public layout of the crowd binding relation drops from 40 limbs to 32, the AIR
binds eight output limbs instead of sixteen, and every contract and fixture
reading that layout moves with it. 44 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SviQ6pDJGd4bPLecnfXdpP
…tion security"

This project has been writing "soundness is roughly queries x log_blowup bits"
and calling 40 queries "full production security, no discount". Neither
sentence had a number behind it. soundness_budget.rs supplies the model — non-
interactive STARK via Fiat-Shamir in the ROM, challenges in F^3 of size 2^93 —
and every error term: query phase in both the proved Johnson regime and the
conjectured capacity regime, grinding, proximity-gap batching, DEEP
out-of-domain sampling, per-round folding, and the Fiat-Shamir cost read as
work rather than as probability.

Computed: every configuration deployed here costs about 2^48 hashes to forge
under the CONJECTURE and 2^28 under the proved bound. The additive terms sit
at 2^-81 and never bind. Forty queries was never "production": at rate 1/2
each query is worth one conjectured bit, so forty queries is forty bits.

The same accounting names the lever, and measurement confirms it is dominant
on every axis at once. Bits scale as queries x log_blowup while envelope
scales with queries alone, so trading queries for blowup buys security in
prover time rather than in transaction bytes:

  q=20 blowup   4 (deployed)  2^48   291.0M CPU (73%)  123,399 B (93%)
  q=16 blowup  64             2^104  253.1M CPU (63%)  122,310 B (93%)
  q=12 blowup 128             2^92   199.7M CPU (50%)   99,509 B (75%)

Sixteen queries at blowup 64 more than doubles the security level while
spending LESS verifier CPU and the same envelope. That is not a tradeoff; the
deployed point was simply the wrong corner of the parameter space, chosen when
"more queries" was mistaken for "more security".

Also in this commit: Theorem A's normal form is now PROVED rather than
verified. The coordinate ring F[x,y]/(x^2+y^2-1) is free of rank two over F[x]
on the basis {1,y}, since y^2 = 1-x^2 rewrites every even power into F[x] and
every odd power into y*F[x]; uniqueness follows by comparing free-module
components. What remains verified rather than proved is which degree bounds cut
out the particular N-dimensional subspace CFFT interpolates into, and that
distinction is now stated instead of blurred.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
…herited

This project has called its construction statistical, then computational, zero-
knowledge, having analysed exactly ONE component of the verifier's view: the
trace openings. Zero-knowledge is a statement about the whole view and is
established by exhibiting a simulator. None was exhibited. zk_view.rs
enumerates the view instead of gesturing at it, and proves the second
component.

Theorem D: for zeta outside every domain, the out-of-domain block
(trace_local, trace_next, quotient_chunks) is uniform on the variety cut out by
the single equation the verifier checks, and that distribution does not depend
on the witness. The trace values at zeta and zeta_next are uniform by Theorem
B; they FIX the weighted chunk sum through the public constraint; the chunk
randomisation makes d-1 of the opened chunks uniform and determines the last.
The corollary is the point: a simulator can sample that entire block knowing
only the public statement.

What that leaves, stated as a table rather than a mood. Proved: trace openings
(Theorem B), out-of-domain block (Theorem D). Inherited and simulated by
programming the oracle: leaf salts and Merkle paths, which is why a salted MMCS
is not optional. NOT analysed here: the FRI layer commitments and siblings, and
the randomisation polynomial folded into the batch — which is precisely where
upstream's own "statistical" qualifier originates.

The simulator's step 4 is where the argument stops: a simulator that sampled
the first FRI layer freely must still make every later layer consistent with
the folding relation. That is not a formality and it is not done here.

So the honest claim is now written as: the trace openings and the out-of-domain
view are proved witness-independent; full zero-knowledge is inherited from
Plonky3's construction and is not independently established in this repository.
Weaker than "the system is zero-knowledge", stronger than "we hope so", and it
is what the evidence supports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
…both

binding.rs published leaf = pi(secret || action) and nullifier =
pi(secret || round) as full sixteen-limb permutation states, with action and
round public beside them. Poseidon2 is a bijection, so one inversion on either
published value returned the secret. PRIVACY.md had reported this path as
leaking via interpolation across 40 queries; the real mechanism needed no proof
at all. The witness-hiding path in zk.rs and the cost instrument in hiding.rs
carried the identical defect, since they publish the same two values.

All three now publish digests, as membership and the crowd path already did.
The legacy binding layout drops from 48 limbs to 32 and the full relation from
56 to 40. relation.rs no longer truncates when composing, because the value it
was truncating already arrives as a digest — the composition check is
unchanged in meaning.

44 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SviQ6pDJGd4bPLecnfXdpP
… was negative

zk_view.rs left the FRI phase open and called it research. It is less open than
that, because of one structural fact: FRI folding is LINEAR, so every scalar
the verifier observes during the FRI phase is the image of the batch polynomial
under a linear map fixed by public challenges. "Can a simulator produce a
consistent FRI transcript" is therefore the Theorem B question one level up:
is the blinding subspace surjective onto the FRI observation map?

Counting. Observations are m = Q * l, one sibling per query per folding layer,
with l = log2(2N) + log_blowup. Blinding is 3N and only 3N: the per-column
trace blinders collapse to N dimensions under the alpha reduction — the trace
WIDTH does not help, which is the counterintuitive part — plus 2N from the
randomisation polynomial.

The margin 3N - m, computed:

  32 rows, 20 queries, blowup 4     m=160  3N= 96   margin  -64
  64 rows, 20 queries, blowup 4     m=180  3N=192   margin  +12
  64 rows, 16 queries, blowup 64    m=208  3N=192   margin  -16
  128 rows, 12 queries, blowup 128  m=180  3N=384   margin +204

The 32-row configuration this repository deployed until tonight has FEWER
blinding dimensions than the FRI phase reveals scalars. A negative margin is
conclusive in the bad direction: a linear functional of the witness survives
into the transcript and no simulator of this shape exists. The 64-row
configuration adopted hours ago clears it by twelve.

And the configuration the soundness budget recommended — 16 queries at blowup
64, 104 conjectured bits for less CPU — has margin -16. Adopting it on the
soundness argument alone would have traded away a zero-knowledge property to
buy a soundness number, invisibly.

The interaction, which was unobservable while the FRI phase was inherited
rather than counted: soundness improves as Q * log_blowup, the zero-knowledge
margin degrades as Q * (log_committed + log_blowup). Same knob, opposite
directions, and the zero-knowledge side degrades faster because it pays the
committed height on top of the blowup.

The lever that helps both is trace HEIGHT: three blinding dimensions per row
against Q observations per doubling. Measured, 128 rows at 12 queries and
blowup 128 beats the deployed point on every axis at once — 2^92 conjectured
soundness against 2^48, margin +204 against +12, 206.3M verifier instructions
against 291.0M, and 106,491 envelope bytes against 123,399.

Scope: dimension counting is necessary, not sufficient. A positive margin means
the blinding COULD cover the observations; establishing that it does is the
surjectivity computation, not done here. A negative margin needs no further
argument.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
…, remade

fri_zk_budget.rs concluded that 128 rows at 12 queries and blowup 128 beats the
deployed point "on every axis at once", having measured soundness, the
zero-knowledge margin, verifier CPU and envelope. It described the blowup as
buying security "in prover time rather than transaction bytes" and then did not
measure prover time. The LDE is blowup x the trace; at blowup 128 that is a
128-fold increase in the dominant prover cost. The crowd test suite went from 9
seconds to 593.

Measured across the parameter space, all four constraints at once:

  64 rows,  20q, blowup   4   2^48   margin  +12  123,415 B    384 ms
  128 rows, 28q, blowup   8   2^92   margin  +76  188,163 B    609 ms  OVER envelope
  128 rows, 46q, blowup   4   2^100  margin  -76  283,104 B    345 ms  OVER both
  128 rows, 16q, blowup  64   2^104  margin +160  131,261 B  3,211 ms  99.4% envelope
  128 rows, 12q, blowup 128   2^92   margin +204  106,491 B  8,634 ms

The prediction that the cheap end would be high-query and low-blowup was right
about prover time and wrong about what matters: those points do not fit the
envelope. The envelope FORCES a high blowup, so the prover cost is not a choice
that was made badly, it is the price of the constraint.

Adopted: 128 rows, 12 queries, blowup 128. Nearly double the soundness of the
point it replaces, seventeen times the zero-knowledge margin, less verifier CPU
and twelve points more envelope headroom — and 22x the prover time, from 384 ms
to 8.6 seconds. The 16-query point has better soundness and less prover time
but sits at 99.4% of the envelope, and proof sizes vary by seed, so it was
rejected on the margin that matters least until the day it does.

Recorded because it is the pattern of this whole session: a recommendation made
on three axes had to be remade on four, and the sentence "better on every axis,
no trade-off" was wrong when written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
…em first

Prompted by a reader's correction: a quantum adversary is not merely unable to
Shor a hash-based STARK, it is Grover-accelerated against it, and this file was
computing classical work only.

Grover applies twice and both times to the number that matters. Forging is a
search for a transcript whose Fiat-Shamir challenges are favourable, and Grover
turns an unstructured search of size S into sqrt(S), so the bits halve. The
grinding is exactly the search Grover was built for, so w grinding bits are
worth w/2. Both are the same square root, so the whole work figure halves. The
file now reports both columns, and the quantum one is what a project whose
entire pitch is post-quantum should be judged on: being immune to Shor is not
the same as being unaffected.

Adding the column surfaced a second limit the mnemonic could never have shown.
The additive terms are domain size over |E|; the domain grows with the blowup
and |E| = 2^93 does not. At blowup 128 the round-by-round error floors at 2^-74
however many queries are added — the query phase offers 84 bits and the field
lets 74 through. So the adopted configuration is 2^82 classical and 2^41
quantum, not the 2^92 that Q x log_blowup + grind predicted. The naive formula
overstated it by ten bits, in the direction that flatters.

The fix is a change to the field rather than to the protocol: degree 4 instead
of degree 3 moves |E| from 2^93 to 2^124 and lifts the ceiling. Untested, and
named as the next parameter to try.

Also in this commit: prover_cost.rs, the measurement that showed the fifth axis
and cost the previous recommendation its "no trade-off" claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
The queries and log_blowup were compile-time constants, so pricing a different
point meant editing and rebuilding. They are now optional trailing arguments,
defaulting to the shipped configuration. The pair is the knob that decides both
the soundness in bits and whether verification still fits Soroban's 400M
instruction ceiling, so it belongs on the command line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SviQ6pDJGd4bPLecnfXdpP
074873c and e56b504 truncated the published leaf and nullifier to the 8-limb
digest, because publishing all 16 limbs published a permutation's entire output
and a permutation is invertible: anyone reading the transaction could run it
backwards and recover the secret, whatever the proof was hiding. prove_action
and prove_action_zk still formatted them as [u64; 16] and stopped compiling.

Both now take a slice, and both carry the reason in a comment rather than the
shape alone, so the next person to widen them has to read why they are narrow.

Note for whoever redeploys: this is a WIRE FORMAT change. The legacy binding
publics went from 48 little-endian u64s to 32, so the deployed m31-verify with
PUBLICS_BYTES = 48 * 8 and rr-layer's BINDING_PUBLICS_BYTES no longer match
what the prover emits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
The soundness accounting found that at high blowup the additive error terms
bind. They are domain over |E|, the domain grows with the blowup, and |E| does
not: at blowup 128 the round-by-round error floored at 2^-74 while the query
phase was offering 84 bits. Ten bits thrown away by a type alias, and a ceiling
no amount of extra querying could lift.

p3-mersenne-31 already ships QM31, the degree-4 extension M31[i][u] with
i^2 = -1 and u^2 = 2 + i, at 4 * 31 = 124 bits, with the ExtensionField
<Mersenne31> impls a challenge field needs. Changing `type Challenge` was the
entire change; nothing else in the crate needed touching.

Measured, on the adopted 128-row 12-query blowup-128 configuration:

  soundness       82 -> 92 bits classical, 41 -> 46 quantum
  proof size      106,491 -> 113,297 B, 81% -> 86% of the envelope
  prover time     8,634 -> 10,622 ms
  FRI zk margin   +204, unchanged

44 library tests green. The size growth is 6.4%, not the 20-30% predicted here
before measuring: the opened trace rows dominate the proof and they live in the
BASE field, so only the out-of-domain openings, the FRI siblings and the final
polynomial widen.

One side effect that confirms an earlier call: 16 queries at blowup 64 sat at
99.4% of the envelope and was rejected for that margin; under the wider field
it measures 138,497 B and would not fit at all.

What this buys beyond the ten bits is the ceiling itself. With degree 3 no
configuration could pass ~85 bits regardless of parameters; the query phase and
the field now agree at 92 instead of disagreeing at 92 and 82.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
membership.rs said the authentication path "stays hidden" and m31-verify
repeated it. prove_membership commits with a non-hiding MMCS over CirclePcs,
whose ZK flag is false, so the siblings and direction bits are trace cells and
the FRI openings at 40 queries over a short trace interpolate them. An observer
who reads the proof recovers the path. Keeping a value out of the public inputs
moves it; it does not hide it.

Only prove_membership_zk and the crowd variants hide it, under
HidingCirclePcs. Found by the adversarial pass as M5 and corrected here rather
than softened: a reader who took the old sentence at face value was misled
about the one property the sentence was about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SviQ6pDJGd4bPLecnfXdpP
…it is tiny

The append-only-history AIR proves what a regulator needs and a Groth16 snapshot
cannot: that the ASP's published roots form a consistent chain — monotone
gap-free indices, root chaining, endpoints pinned to public values — so a
reordered or leaf-injected history is unforgeable regardless of hash strength.
The BN254-Poseidon2 compression is a witnessed oracle, not reproven, because the
compliance property is the shape of the history, not the hash's preimage
resistance.

Six tests green, including the two that matter: a reordered history and an
injected duplicate-index leaf are both unprovable, not merely unverifiable — the
prover fails closed rather than emit a proof of a forged chain.

Measured, because "passes natively" is not "fits on-chain": attesting 250 root
updates is 47,922 bytes (36% of the envelope) and proves in 14ms. It is far
cheaper than every other proof in this crate for one reason — it reproves no
hash, so the AIR is structure-only. Chunk count pins at 0, confirmed by the
symbolic pass, because the constraints are linear plus one boolean quadratic.

This is what makes the compliance-layer submission whole: Layer 2 captures the
history the RPC deletes at day 7, Layer 3 proves that history is honest, and
both outlive the quantum threat and the trusted setup that the Groth16 roots do
not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
…o a proof

Reads the ASP LeafAddedEvent steps the durable index captured — [{index, root}]
— and produces the Layer-3 append-only-chain attestation plus its public values
(start index, first and last root limbs), so a verifier or an on-chain contract
can check that the ASP's published root history is honest without a trusted
setup and without a quantum expiry date.

The BN254 root hex is split into the tag limbs the AIR carries; the AIR does no
arithmetic on the root, it constrains the shape of the history around it.
Measured end to end: a 3-step history proves to 11,531 bytes. This is the seam
that makes the compliance-layer submission whole — the index remembers what the
RPC deletes at day 7, and this proves that memory is an honest chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZrBGFG4nRRH8tmQkAMS1Y
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants