diff --git a/README.md b/README.md index 48406857..29cb73b5 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Every row is checkable in this repository, and the right-hand column says where. | what is asked | how this meets it | check it | |---|---|---| | **Rust, end to end** | **Zero** files of any other language are tracked here. No Circom, no snarkjs, no `ethers`, no TypeScript build step, no shell scripts doing real work. The circuit is an arkworks R1CS gadget in `crates/mirror-circuit`; the prover is Rust; the verifier is the on-chain program calling the `alt_bn128` syscall. | `git ls-files '*.js' '*.ts' '*.py' '*.sol' '*.circom'` returns nothing | -| **Production-grade, tested, deployable** | 261 tests. The end-to-end suite loads the compiled `.so` into a real SVM and verifies real Groth16 proofs through the actual syscall. Negative cases assert the program's *own* error codes, not that something failed. `overflow-checks` on in release; `cargo-deny` over advisories, bans, licences and sources; CI actions pinned by commit SHA. | `make verify` | +| **Production-grade, tested, deployable** | 275 tests. The end-to-end suite loads the compiled `.so` into a real SVM and verifies real Groth16 proofs through the actual syscall. Negative cases assert the program's *own* error codes, not that something failed. `overflow-checks` on in release; `cargo-deny` over advisories, bans, licences and sources; CI actions pinned by commit SHA. | `make verify` | | **Deployed and running** | Live on devnet, with every claim in this file linking to the transaction behind it. The full lifecycle — pool, deposits, proofs, batched settlement, and four rejections — is recorded with signatures. | [`docs/PROOF.md`](docs/PROOF.md) | | **Scalable & customizable** | Adding a protocol requires no change to the on-chain program — no redeploy, no new circuit, no governance. Selector 1 invokes any program with any payload; selector 2 additionally makes the pool *sign* as the member's authority, which is what a stake delegation or a governance vote needs. The whole procedure is four steps with a worked `DelegateStake` that runs on devnet. | [`docs/INTEGRATING.md`](docs/INTEGRATING.md) | | **Realistic** | The anonymity number is computed from live mainnet chain data, with the sample committed so the result reproduces without RPC access — and it is pointed at a pool this project neither controls nor funded, because measuring our own empty pool would be measuring nothing. | [`docs/MEASUREMENT_LOG.md`](docs/MEASUREMENT_LOG.md) | @@ -119,7 +119,7 @@ Then: ``` git clone https://github.com/solanabr/mirror-pool && cd mirror-pool -make verify # fmt, clippy -D warnings, build-sbf, 261 tests +make verify # fmt, clippy -D warnings, build-sbf, 275 tests ``` Nothing in that command needs a network, an API key or an account with anybody, @@ -287,7 +287,8 @@ repository publishes both and never quotes the first alone. **The incentives are structural, and one of them is missing.** Four are enforced by the program rather than recommended: you cannot act until a crowd exists -(`k_floor`), waiting is never a hostage situation (the one-hour timeout), a relay +(`k_floor`), waiting is never a hostage situation (a timeout the pool sets, an +hour by default), a relay is paid out of the denomination to sign so that you never do, and a batch whose members paid different fees is refused outright — so converging on a common fee is a rule, not advice. @@ -313,7 +314,7 @@ rather than half-present. | `crates/mirror-provenance` | The funding-provenance measurement. | | `crates/mirror-cli` | The tool. `init-pool`, `note-new`, `deposit`, `tree`, `spend`, `settle`, `disclose`, `disclose-verify` for members; `setup`, `verify-setup`, `soak`, `crowd`, `close-table` for operators; `check-endpoint`, `seeds`, `collect`, `analyze`, `compare`, `selection` for the measurement. | -**261 tests.** The end-to-end suite loads the `.so` that `make build-sbf` +**275 tests.** The end-to-end suite 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 — so a divergence between what the host believes and what the chain does cannot pass unnoticed. @@ -331,6 +332,12 @@ mirror spend --program $P --note m1.json \ mirror settle --program $P --denomination D # permissionless ``` +A batch below the pool's floor needs `--allow-below-floor`, and the settlement it +produces is marked as such on chain. The timeout that lets an under-floor batch +settle is a solvency guarantee and not an anonymity one, so asking for it is a +decision rather than a default — see +[the crowd rule](docs/THREAT_MODEL.md#the-crowd-rule-is-threshold-or-timeout-and-the-timeout-side-has-no-floor). + `docs/USAGE.md` is the walkthrough, and every line of output in it was produced by running the command against devnet. It covers three more a member may want: `init-pool`, which anyone can run, and `disclose` / `disclose-verify`, which @@ -445,14 +452,14 @@ a table automatically for any batch that will not fit legacy. Twenty members settled that way on devnet: ``` -20 spends do not fit a legacy transaction: 2218 bytes, 986 over the 1232-byte packet. +20 spends do not fit a legacy transaction: 2219 bytes, 987 over the 1232-byte packet. Settling through a lookup table instead. - settlement is 332 bytes of 1232, one signature + settlement is 333 bytes of 1232, one signature ``` -[`enxa9fztmzEHM…`](https://explorer.solana.com/tx/enxa9fztmzEHMLsvhfzJwFVRsNWha7WiSEUEeNn7UPk8KAdpnWQDzEHarGL8d4ckyBvtWrEGCkuvgW7UFgFAHzp?cluster=devnet) +[`4rKtNcZyAqxf5…`](https://explorer.solana.com/tx/4rKtNcZyAqxf5EAXNCtXpde9Zt3qqEEou4cG8GFV1vVA1kGGxFRBaBvZWNfsxVY9d9FgwTyYXYzz8dahaXenGM5C?cluster=devnet) — twenty payouts, `numRequiredSignatures: 1`, 2 static keys and 62 resolved -through the table, 35,895 CU. Twenty recipients and twenty relays are named in +through the table, 35,941 CU. Twenty recipients and twenty relays are named in that transaction and **not one of them signed it**. Nothing in the program changes for this. `settle_epoch` requires a signature from @@ -955,7 +962,9 @@ multi-party ceremony, not more SOL. publishing the entropy while withholding the proving key produces — nobody can verify the key, and nobody can regenerate it either. - The on-chain `k_floor` bounds **program-visible membership** only. That is all - a program can check. + a program can check. It also bounds only the batches that settle *by crowd*: a + batch that settles on the timeout can be smaller, which is why it has to be + asked for and why the program marks the settlement when it happens. - **Not that privacy pools attract more concentrated funding than ordinary users.** We measured a control to find out, the point estimates say they do, and the sample does not support saying it. `ρ`'s comparability across diff --git a/crates/mirror-cli/src/chain.rs b/crates/mirror-cli/src/chain.rs index 05a7dac9..49ce05f4 100644 --- a/crates/mirror-cli/src/chain.rs +++ b/crates/mirror-cli/src/chain.rs @@ -407,4 +407,26 @@ impl Chain { .and_then(|c| c.as_u64()) .ok_or_else(|| anyhow!("getTransaction: {signature} reports no compute units")) } + + /// The finalized slot a transaction landed in. + /// + /// Recorded beside every published signature because devnet history is + /// pruned. A signature whose `getTransaction` has aged out returns null, and + /// a reader checking the evidence months later cannot tell that from a + /// signature that never existed. The slot is what makes the difference + /// recoverable: `getSignatureStatuses --search-transaction-history` still + /// answers for a pruned transaction, and the slot is what its answer is + /// checked against. + pub fn slot(&self, signature: &str) -> Result { + let v = self.call( + "getTransaction", + serde_json::json!([ + signature, + { "commitment": "confirmed", "maxSupportedTransactionVersion": 0 } + ]), + )?; + v.pointer("/slot") + .and_then(|s| s.as_u64()) + .ok_or_else(|| anyhow!("getTransaction: {signature} reports no slot")) + } } diff --git a/crates/mirror-cli/src/client.rs b/crates/mirror-cli/src/client.rs index cac7ae52..275c9066 100644 --- a/crates/mirror-cli/src/client.rs +++ b/crates/mirror-cli/src/client.rs @@ -37,6 +37,9 @@ pub struct PoolState { pub deposits: u64, pub spends: u64, pub root: [u8; 32], + /// The pool's own settlement timeout, already resolved against the + /// program's default for a pool that never set one. + pub settle_timeout: i64, } pub fn read_pool(chain: &Chain, program_id: &Pubkey, denomination: u64) -> Result { @@ -61,6 +64,10 @@ pub fn read_pool(chain: &Chain, program_id: &Pubkey, denomination: u64) -> Resul .current_root() .map_err(|e| anyhow!("{e:?}"))? .to_bytes(), + settle_timeout: state.settle_timeout_seconds().map_or( + mirror_pool_program::processor::SETTLE_TIMEOUT_SECONDS, + i64::from, + ), }) } @@ -79,6 +86,7 @@ pub fn init_pool( program_id: &Pubkey, denomination: u64, k_floor: u32, + settle_timeout_seconds: u32, payer: &Keypair, ) -> Result<()> { let (pool, _) = pool_address(program_id, denomination); @@ -88,12 +96,36 @@ pub fn init_pool( println!("nothing to do — one pool per denomination is the whole point"); return Ok(()); } + // Zero means the program's default, and anything else has to be a timeout + // somebody could live with. Refused here as well as on chain so the caller + // learns it before paying for a transaction, and refused rather than + // clamped because the value is immutable once the pool exists. + if settle_timeout_seconds != 0 + && !(mirror_pool_program::state::MIN_SETTLE_TIMEOUT_SECONDS + ..=mirror_pool_program::state::MAX_SETTLE_TIMEOUT_SECONDS) + .contains(&settle_timeout_seconds) + { + return Err(anyhow!( + "a settlement timeout of {settle_timeout_seconds}s is outside the permitted \ + range of {}s to {}s. A pool's timeout is fixed at creation and cannot be \ + changed, so this is refused rather than adjusted. Pass 0 for the program's \ + default of {}s.", + mirror_pool_program::state::MIN_SETTLE_TIMEOUT_SECONDS, + mirror_pool_program::state::MAX_SETTLE_TIMEOUT_SECONDS, + mirror_pool_program::processor::SETTLE_TIMEOUT_SECONDS, + )); + } + let effective_timeout = match settle_timeout_seconds { + 0 => mirror_pool_program::processor::SETTLE_TIMEOUT_SECONDS, + v => i64::from(v), + }; + // A floor above what one settlement can carry is a floor no crowd can ever // satisfy. Settlement locks three accounts per member plus three for the // pool itself, so the largest batch a transaction can hold is fixed by the // runtime and not by this program — and a pool asking for more than that can - // only ever settle through the liveness timeout, an hour at a time, which is - // the opposite of what a high floor is chosen for. + // only ever settle through the liveness timeout, one wait at a time, which + // is the opposite of what a high floor is chosen for. // // Refused rather than warned about: a pool's floor is fixed at creation, so // by the time anyone notices, the fix is a different pool. @@ -106,7 +138,7 @@ pub fn init_pool( crowd, and could only ever settle on the {}s timeout. Choose {settleable} \ or fewer.", lookup::MAX_ACCOUNT_LOCKS, - mirror_pool_program::processor::SETTLE_TIMEOUT_SECONDS, + effective_timeout, )); } let ix = Instruction::new_with_bytes( @@ -115,6 +147,7 @@ pub fn init_pool( denomination, entry_fee: 0, k_floor, + settle_timeout_seconds, } .pack(), vec![ @@ -127,6 +160,14 @@ pub fn init_pool( let sig = send(chain, ix, &[payer])?; println!("pool {pool}"); println!("vault {vault}"); + println!( + "timeout {effective_timeout}s{}", + if settle_timeout_seconds == 0 { + " (the program's default: this pool set none)" + } else { + "" + } + ); println!("signature {sig}"); Ok(()) } @@ -211,6 +252,7 @@ pub fn tree(chain: &Chain, program_id: &Pubkey, denomination: u64) -> Result Result Result<()> { let state = read_pool(chain, program_id, denomination)?; println!("looking for spends waiting to settle:"); @@ -582,7 +630,7 @@ pub fn settle( // refuses with a bare error code — which is exactly what this check exists // to spare a caller. let crowd = ready.len() as u32 >= state.k_floor; - let timeout = mirror_pool_program::processor::SETTLE_TIMEOUT_SECONDS; + let timeout = state.settle_timeout; if !crowd { let youngest = ready.iter().map(|(_, _, _, at, _)| *at).max().unwrap_or(0); let waited = now.saturating_sub(youngest); @@ -635,12 +683,33 @@ pub fn settle( state.k_floor ); } + + // The consent, and it is asked for here rather than assumed. + // + // Warning and then settling anyway is how a settler ends up publishing + // an under-floor batch they never meant to compose — the notice scrolls + // past and the transaction lands. The program refuses this without the + // flag; so does the tool, and for the same reason. + if !allow_below_floor { + println!(); + println!(" Not settling. This is not a failure: the program refuses an under-floor"); + println!(" batch that nobody asked for, and so does this command. If it is what you"); + println!(" intend — usually because the members' funds would otherwise stay escrowed"); + println!(" — re-run with --allow-below-floor."); + println!(); + println!(" The settlement will be marked on chain as having landed below the floor,"); + println!(" so the members can tell afterwards what crowd they actually got."); + return Ok(()); + } } let ix = Instruction::new_with_bytes( *program_id, &MirrorIx::SettleEpoch { count: ready.len() as u8, + // Only ever true on the path that has already printed the warning + // and been told to go ahead: a crowd-sized batch never asks for it. + allow_below_floor: !crowd, } .pack(), metas, @@ -681,6 +750,14 @@ pub fn settle( println!(); println!("Every payout in that batch shares one timestamp and one ordering, which"); println!("is what stops arrival time from telling the members apart."); + println!(); + // The batch size is an anonymity set, and printing one without the measured + // discount is exactly the lift this guards against. + println!( + "The {} members above are that batch's nominal set.", + ready.len() + ); + println!("{}", mirror_provenance::PUBLISHED_HEADLINE.note()); Ok(()) } diff --git a/crates/mirror-cli/src/crowd.rs b/crates/mirror-cli/src/crowd.rs index 91459574..09ed0b3e 100644 --- a/crates/mirror-cli/src/crowd.rs +++ b/crates/mirror-cli/src/crowd.rs @@ -49,7 +49,7 @@ use solana_transaction::Transaction; /// A pool is unique per denomination, so this constant also selects the pool. /// Bump it for a clean run; the note ledger is keyed by it, so a new /// denomination starts a new ledger too. -const DENOMINATION: u64 = 43_000_007; // 0.043 SOL +const DENOMINATION: u64 = 47_000_011; // 0.047 SOL const ENTRY_FEE: u64 = 0; const RELAY_FEE: u64 = 200_000; @@ -162,6 +162,14 @@ pub struct StepRecord { pub name: String, pub signature: String, pub note: String, + /// The finalized slot, so a pruned transaction stays findable. + /// + /// `#[serde(default)]` because the committed result files predate this + /// field. `--render-only` has to keep rebuilding this document from the run + /// that produced it, and a schema change that made those files unreadable + /// would break the one property that makes the numbers checkable. + #[serde(default)] + pub slot: Option, } pub struct Crowd { @@ -203,10 +211,15 @@ impl Crowd { fn record(&mut self, name: &'static str, signature: String, note: String) { println!(" {name:<28} {signature}"); + // Read back from the cluster, for the same reason the soak does it: + // devnet prunes, and a signature with no slot beside it is not + // recoverable once `getTransaction` has forgotten the transaction. + let slot = self.client.slot(&signature).ok(); self.steps.push(Step { name, signature, note, + slot, }); } @@ -266,6 +279,9 @@ impl Crowd { denomination: DENOMINATION, entry_fee: ENTRY_FEE, k_floor, + // The program's default. A run that needed its own timeout + // would be measuring a pool nobody else would create. + settle_timeout_seconds: 0, } .pack(), self.pool_metas(), @@ -542,6 +558,12 @@ impl Crowd { self.program_id, &MirrorIx::SettleEpoch { count: batch.len() as u8, + // False, and that is an assertion rather than a default: these + // batches are supposed to meet the floor. If one ever does not, + // the program refuses it and the run stops — which is the + // failure we would want, instead of evidence quietly recording + // a crowd that was not there. + allow_below_floor: false, } .pack(), metas, @@ -912,6 +934,7 @@ pub fn run(program: &str, url: &str, keypair: &str, out: &std::path::Path) -> Re name: s.name.to_string(), signature: s.signature.clone(), note: s.note.clone(), + slot: s.slot, }) .collect(), }; @@ -1124,7 +1147,7 @@ fn report(outcome: &Outcome) -> String { **A lookup table lifts that, and here is how far.** Naming accounts by one \ byte each takes the packet out of the way — `mirror settle` does it \ automatically, and a batch of twenty plain transfers settled that way on devnet \ - at 332 bytes of 1232. What takes over for *delegations* is the 64-account lock \ + at 333 bytes of 1232. What takes over for *delegations* is the 64-account lock \ limit, and it is a different kind of limit: bytes are spent naming an account, \ locks are held per **distinct** account.\n\n\ | batch | legacy packet | through a lookup table |\n|---|---|---|\n\ @@ -1166,16 +1189,24 @@ fn report(outcome: &Outcome) -> String { )); md.push_str("## Every step\n\n"); - md.push_str("| step | signature | note |\n|---|---|---|\n"); + md.push_str("| step | signature | slot | note |\n|---|---|---|---|\n"); for step in steps { md.push_str(&format!( - "| {} | {} | {} |\n", + "| {} | {} | {} | {} |\n", step.name, explorer("tx", &step.signature), + step.slot.map_or_else(|| "—".to_string(), |n| n.to_string()), step.note )); } md.push('\n'); + md.push_str( + "Devnet history is pruned, so a signature above may one day return null from \ + `getTransaction` without having failed. The slot is what tells those two apart: \ + `getSignatureStatuses` with `--search-transaction-history` still answers for a \ + pruned transaction. A dash means this run could not read the slot back, and that \ + row is the one to check by hand.\n\n", + ); md.push_str("## Reproducing this document\n\n"); md.push_str(&format!( @@ -1200,15 +1231,38 @@ fn report(outcome: &Outcome) -> String { // the published headline rather than asserted, because the point of the // section is that the number comes out *good* and means nothing. let own = mirror_provenance::Anonymity::from_class_sizes(&[members as u64]); - if let Some(a) = own { + let own_bracket = mirror_provenance::Bracket::new(&[members as u64], 0); + if let (Some(a), Some(b)) = (own, own_bracket) { + // The bracket travels with the figure here too, even though this run + // resolved everybody and it therefore collapses onto the point. Showing + // the collapse is worth a column: it is the difference between "no + // unresolved members" and "unresolved members nobody accounted for", + // and a table that omits the bracket whenever it is narrow teaches a + // reader that the bracket is optional. + md.push_str(&format!( + "| quantity | this run | unresolved bracket |\n|---|---|---|\n\ + | nominal k | {} | — |\n\ + | provenance classes | {} | — |\n\ + | ρ, the loss factor | {:.4} | {:.4} … {:.4} |\n\ + | effective k (Shannon) | {:.2} | {:.2} … {:.2} |\n\ + | effective k (min-entropy) | {:.2} | {:.2} … {:.2} |\n\n", + a.nominal_k, + a.classes, + a.loss_factor, + b.lower.loss_factor, + b.upper.loss_factor, + a.eff_k_shannon, + b.lower.eff_k_shannon, + b.upper.eff_k_shannon, + a.eff_k_min_entropy, + b.lower.eff_k_min_entropy, + b.upper.eff_k_min_entropy, + )); md.push_str(&format!( - "| quantity | this run |\n|---|---|\n\ - | nominal k | {} |\n\ - | provenance classes | {} |\n\ - | ρ, the loss factor | {:.4} |\n\ - | effective k (Shannon) | {:.2} |\n\ - | effective k (min-entropy) | {:.2} |\n\n", - a.nominal_k, a.classes, a.loss_factor, a.eff_k_shannon, a.eff_k_min_entropy + "The bracket collapses onto the point because all {} members resolved and none \ + were left over — not because the figure needs no bracket. Every ρ this \ + repository publishes carries one.\n\n", + b.resolved )); md.push_str(&format!( "**ρ = {:.4} is the best value the metric can return, and it is meaningless \ diff --git a/crates/mirror-cli/src/main.rs b/crates/mirror-cli/src/main.rs index ad654ae1..f1fb6399 100644 --- a/crates/mirror-cli/src/main.rs +++ b/crates/mirror-cli/src/main.rs @@ -294,6 +294,12 @@ enum Command { /// Notes the pool must hold before it will act. #[arg(long, default_value_t = 2)] k_floor: u32, + /// How long a spend waits before it may settle below the floor. + /// + /// Fixed at creation and never changeable, like the floor itself. Zero + /// takes the program's default of one hour. + #[arg(long, default_value_t = 0)] + settle_timeout: u32, #[arg(long, default_value = DEFAULT_URL)] url: String, #[arg(long, default_value = DEFAULT_KEYPAIR)] @@ -380,6 +386,15 @@ enum Command { program: String, #[arg(long)] denomination: u64, + /// Consent to settling a batch smaller than the pool's floor. + /// + /// Without it, a batch below the floor is reported and left alone. The + /// program refuses one that nobody asked for, and a settlement that + /// does land below the floor is marked as such on chain — the members + /// in it got a smaller crowd than the pool advertises, and that should + /// be somebody's decision rather than a default. + #[arg(long)] + allow_below_floor: bool, #[arg(long, default_value = DEFAULT_URL)] url: String, #[arg(long, default_value = DEFAULT_KEYPAIR)] @@ -713,109 +728,72 @@ fn main() -> Result<()> { println!("no member resolved to a class; nothing to report"); } Some(a) => { - println!("resolved members {}", a.nominal_k); - println!("provenance classes {}", a.classes); - println!(); - println!( - "loss factor rho {:.4} <- headline, independent of k", - a.loss_factor - ); - println!("effective-k Shannon {:.4}", a.eff_k_shannon); - println!("effective-k min-ent {:.4}", a.eff_k_min_entropy); - println!("leakage Shannon {:.4} bits", a.leakage_shannon_bits); - println!( - "leakage min-entropy {:.4} bits", - a.leakage_min_entropy_bits + // The bracket is built first and the report refuses without + // it. A point estimate alone does not say whether the number + // is driven by what was measured or by what was not, and a + // reader who copies one line out of this output should not + // be able to end up holding a bare effective-k. + let mut sizes: std::collections::BTreeMap<&str, u64> = + std::collections::BTreeMap::new(); + for l in &labels { + *sizes.entry(l.as_str()).or_insert(0) += 1; + } + let resolved_sizes: Vec = sizes.into_values().collect(); + let Some(bracket) = + mirror_provenance::Bracket::new(&resolved_sizes, unresolved) + else { + eprintln!( + "REFUSING to report: the resolved members do not form a partition \ + this can bracket, so any effective-k printed here would be a point \ + estimate with nothing to say how much of it is the pool and how \ + much is the tracer's budget." + ); + std::process::exit(3); + }; + + // Sampling error, which the bracket does not cover. These + // depositors are a draw from a larger population, and + // without an interval over that draw a reader cannot tell a + // real difference between two pools from a lucky sample. + let sampling = mirror_provenance::loss_factor_interval( + &labels, + mirror_provenance::bootstrap::DEFAULT_REPLICATES, + mirror_provenance::bootstrap::DEFAULT_SEED, ); - println!("guessing entropy {:.2}", a.guessing_entropy); - println!("Good-Turing coverage {:.4}", a.good_turing_coverage); - println!("Chao1 richness {:.2}", a.chao1); + + // One renderer, and it is the only thing that can print an + // effective-k. println!( - "worst-case class {}{}", - a.worst_case, - if a.worst_case_is_informative() { - "" - } else { - " (not informative: under any heavy-tailed prior somebody is always alone)" - } + "{}", + mirror_provenance::Quotation::new(a.clone(), bracket) + .with_sampling(sampling.clone()) ); + println!(); println!("class-size CCDF (share of members in a class of at most t):"); for (t, share) in &a.class_size_ccdf { println!(" t={t:<4} {:.4}", share); } - // Sampling error, which the bracket below does not cover. - // These depositors are a draw from a larger population, and - // without an interval over that draw a reader cannot tell a - // real difference between two pools from a lucky sample. - if let Some(i) = mirror_provenance::loss_factor_interval( - &labels, - mirror_provenance::bootstrap::DEFAULT_REPLICATES, - mirror_provenance::bootstrap::DEFAULT_SEED, - ) { - println!(); - println!( - "rho under resampling {:.4} .. {:.4} (2.5-97.5%, {} replicates)", - i.lo, i.hi, i.replicates - ); - println!( - " resampling bias {:+.4} (mean {:.4} against a point estimate \ - of {:.4})", - i.resampling_bias(), - i.mean, - i.point - ); + if let Some(i) = &sampling { if !i.contains_point() { + println!(); println!( - " The range does not contain the point estimate, and that is a \ - property of this\n population rather than an error. Resampling \ - leaves about 37% of members unpicked,\n so single-member classes \ - vanish from most replicates; fewer classes means lower\n H(C) \ - and therefore higher rho. The size of that gap is a tail \ - diagnostic." + " The resampling range does not contain the point estimate, and \ + that is a\n property of this population rather than an error. \ + Resampling leaves about 37% of\n members unpicked, so \ + single-member classes vanish from most replicates; fewer\n \ + classes means lower H(C) and therefore higher rho. The size of \ + that gap is a\n tail diagnostic." ); } - println!( - " This is the spread of the estimator, not its distance from the \ - truth. Plug-in\n entropy is biased low at small n, so rho is biased \ - HIGH: the real loss factor is\n plausibly below all of this, and \ - equally so for any population measured this way." - ); - } - - // The bracket. A point estimate alone would not say whether - // the number is driven by what was measured or by what was - // not. - let mut sizes: std::collections::BTreeMap<&str, u64> = - std::collections::BTreeMap::new(); - for l in &labels { - *sizes.entry(l.as_str()).or_insert(0) += 1; - } - let resolved_sizes: Vec = sizes.into_values().collect(); - if let Some(b) = mirror_provenance::Bracket::new(&resolved_sizes, unresolved) { println!(); println!( - "unresolved bracket ({} resolved, {} unresolved):", - b.resolved, b.unresolved - ); - println!( - " rho {:.4} .. {:.4}", - b.lower.loss_factor, b.upper.loss_factor + " Resampling is the spread of the estimator, not its distance from \ + the truth.\n Plug-in entropy is biased low at small n, so rho is \ + biased HIGH: the real loss\n factor is plausibly below all of this, \ + and equally so for any population measured\n this way." ); - println!( - " effective-k {:.4} .. {:.4}", - b.lower.eff_k_shannon, b.upper.eff_k_shannon - ); - if !b.is_informative() { - println!(); - println!( - " NOT INFORMATIVE: fewer than half the members reached a class, so \ - the two readings\n diverge and either one quoted alone would \ - describe the sampling budget rather than\n the pool. The point \ - estimate above is reported for completeness, not as a result." - ); - } } if a.good_turing_coverage < 0.8 { @@ -1084,6 +1062,7 @@ fn main() -> Result<()> { program, denomination, k_floor, + settle_timeout, url, keypair, } => { @@ -1094,6 +1073,7 @@ fn main() -> Result<()> { &parse_program(&program)?, denomination, k_floor, + settle_timeout, &payer, ) } @@ -1163,6 +1143,7 @@ fn main() -> Result<()> { Command::Settle { program, denomination, + allow_below_floor, url, keypair, } => { @@ -1175,6 +1156,7 @@ fn main() -> Result<()> { denomination, &settler, now, + allow_below_floor, ) } Command::VerifySetup { seed, expect } => { diff --git a/crates/mirror-cli/src/soak.rs b/crates/mirror-cli/src/soak.rs index 591af227..edcb37c6 100644 --- a/crates/mirror-cli/src/soak.rs +++ b/crates/mirror-cli/src/soak.rs @@ -26,7 +26,7 @@ use solana_transaction::Transaction; // pool. Changing it starts a clean one, which is how the evidence run gets to // record its own `init_pool` rather than reusing a pool an earlier run created // and leaving the creation step undocumented. -const DENOMINATION: u64 = 20_000_023; // 0.02 SOL +const DENOMINATION: u64 = 20_000_029; // 0.02 SOL const ENTRY_FEE: u64 = 0; const K_FLOOR: u32 = 4; /// Members in the evidence batch. Above `K_FLOOR` so the crowd rule is satisfied @@ -82,6 +82,13 @@ pub struct Step { pub name: &'static str, pub signature: String, pub note: String, + /// The finalized slot, read back from the cluster after the fact. + /// + /// `None` when the lookup itself failed, which is recorded rather than + /// hidden: a missing slot in the published table is a reader's cue that this + /// row is the one to check by hand, and an em dash is more honest than a + /// zero. + pub slot: Option, } /// What the vault actually held, so the solvency claim is checkable from this @@ -162,10 +169,16 @@ impl Soak { fn record(&mut self, name: &'static str, signature: String, note: String) { println!(" {name:<28} {signature}"); + // Read back rather than inferred. A slot this run computed would prove + // nothing about where the transaction actually landed, and the whole + // reason to publish it is so a later reader can find a pruned + // transaction by it. + let slot = self.client.slot(&signature).ok(); self.steps.push(Step { name, signature, note, + slot, }); } @@ -233,6 +246,9 @@ impl Soak { denomination: DENOMINATION, entry_fee: ENTRY_FEE, k_floor: K_FLOOR, + // The program's default. A run that needed its own timeout + // would be measuring a pool nobody else would create. + settle_timeout_seconds: 0, } .pack(), self.account_meta_pool(), @@ -658,6 +674,12 @@ impl Soak { self.program_id, &MirrorIx::SettleEpoch { count: batch.len() as u8, + // False, and that is an assertion rather than a default: these + // batches are supposed to meet the floor. If one ever does not, + // the program refuses it and the run stops — which is the + // failure we would want, instead of evidence quietly recording + // a crowd that was not there. + allow_below_floor: false, } .pack(), metas, @@ -1067,13 +1089,26 @@ fn write_proof(soak: &Soak, url: &str, out: &std::path::Path) -> Result<()> { md.push_str(&format!("- pool: `{}`\n", soak.pool())); md.push_str(&format!("- vault: `{}`\n\n", soak.vault())); - md.push_str("## Flows\n\n| step | signature | note |\n|---|---|---|\n"); + md.push_str("## Flows\n\n| step | signature | slot | note |\n|---|---|---|---|\n"); for s in &soak.steps { md.push_str(&format!( - "| {} | [`{}`](https://explorer.solana.com/tx/{}?cluster={cluster}) | {} |\n", - s.name, s.signature, s.signature, s.note + "| {} | [`{}`](https://explorer.solana.com/tx/{}?cluster={cluster}) | {} | {} |\n", + s.name, + s.signature, + s.signature, + s.slot.map_or_else(|| "—".to_string(), |n| n.to_string()), + s.note )); } + md.push_str( + "\nThe slots are there because **devnet history is pruned**. Every signature above \ + resolved through `getTransaction` when this file was written, and a reader coming \ + to it later may find that call returning null for a transaction that did land. \ + That is the cluster forgetting, not the evidence being wrong, and the way to tell \ + the difference is `getSignatureStatuses` with `--search-transaction-history`, \ + which still answers for a pruned transaction — checked against the slot in this \ + table.\n", + ); if let Some(a) = &soak.accounting { md.push_str("\n## Vault accounting\n\n"); @@ -1215,6 +1250,30 @@ fn write_proof(soak: &Soak, url: &str, out: &std::path::Path) -> Result<()> { wrong reason.\n" )); + md.push_str("\n## The key those proofs were checked against\n\n"); + md.push_str( + "Every proof above was verified on chain against `programs/mirror-pool/src/vk.rs`, \ + and that file is generated. A wrong byte in it is not a compile error, not a \ + failure anywhere else in the suite, and not visible in a diff anyone reads \ + carefully — so the binding between the committed circuit and the deployed key is \ + asserted by a test rather than left to inspection.\n\n\ + `programs/mirror-pool/tests/vk_drift.rs` regenerates the key from the committed \ + seed under the committed `Cargo.lock` and compares it to the program's own \ + constants **element by element**, then separately checks that the digest \ + `README.md` publishes is the digest of that key. Two tests rather than one, \ + because if both fail the key moved and if only the second fails the documentation \ + is stale — and knowing which without reading any code is the point.\n\n\ + The same check by hand, against the deployed program:\n\n\ + ```\n\ + mirror verify-setup --expect \n\ + ```\n\n\ + What this establishes is *reproducibility*, not security. The seed is public, so \ + the toxic waste is public, so proofs against this key are forgeable — which is \ + why the program is on devnet and stays there. A real multi-party ceremony is the \ + prerequisite for anything value-bearing, and `docs/THREAT_MODEL.md` says so \ + rather than leaving it to be discovered.\n", + ); + md.push_str("\n## Scope\n\n"); md.push_str( "Devnet is devnet. This is a live-cluster functional proof, not a claim of \ diff --git a/crates/mirror-cli/tests/no_bare_effective_k.rs b/crates/mirror-cli/tests/no_bare_effective_k.rs new file mode 100644 index 00000000..bf64788b --- /dev/null +++ b/crates/mirror-cli/tests/no_bare_effective_k.rs @@ -0,0 +1,114 @@ +//! The rule R2 asks for, enforced against the source rather than remembered. +//! +//! `mirror-provenance` gives effective-k exactly one renderer — `Quotation`, +//! which cannot be constructed without a `Bracket` — so the only way this tool +//! can print a bare figure is by reaching past that renderer into the raw field +//! and formatting it itself. That is a small, specific thing to do, and it is +//! also the obvious thing to do while adding a line to some output in a hurry. +//! +//! So the check is on the shape of the code. The fields stay public because +//! `bootstrap`, `crowd` and `compare` all compute with them; what is refused is +//! putting one inside something that prints. A test rather than a convention, +//! for the same reason the verifying key has `vk_drift.rs`: a rule nothing +//! checks is a rule that has already been broken somewhere nobody looked. + +use std::path::Path; + +/// The fields that are a figure rather than an input to one. +const FIGURES: [&str; 2] = ["eff_k_shannon", "eff_k_min_entropy"]; + +/// Where a value ends up in front of a reader. +const PRINTERS: [&str; 4] = ["println!", "print!", "eprintln!", "push_str"]; + +/// `crowd.rs` renders `docs/CROWD.md`, and its table carries the bracket in +/// adjacent columns — checked by `the_crowd_table_carries_the_bracket` below +/// rather than taken on faith. Markdown is not something `Quotation`'s plain +/// text can produce, so this is the one place allowed to lay the figures out +/// itself. +const ALLOWED: [&str; 1] = ["crowd.rs"]; + +fn sources() -> Vec<(String, String)> { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut out = Vec::new(); + for entry in std::fs::read_dir(&dir).expect("the crate's src directory must be readable") { + let path = entry.expect("a readable directory entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let name = path + .file_name() + .and_then(|n| n.to_str()) + .expect("a UTF-8 file name") + .to_string(); + out.push(( + name, + std::fs::read_to_string(&path).expect("a readable source file"), + )); + } + assert!( + out.len() > 5, + "the source sweep found almost nothing to read" + ); + out +} + +/// No effective-k reaches a reader except through the renderer that carries its +/// bracket. +#[test] +fn no_module_prints_an_effective_k_by_hand() { + let mut offences = Vec::new(); + + for (name, text) in sources() { + if ALLOWED.contains(&name.as_str()) { + continue; + } + // A print can span several lines, so the unit is the statement rather + // than the line: from a printing macro to the semicolon that ends it. + for (i, line) in text.lines().enumerate() { + if !PRINTERS.iter().any(|p| line.contains(p)) { + continue; + } + let mut statement = String::new(); + for l in text.lines().skip(i) { + statement.push_str(l); + statement.push('\n'); + if l.trim_end().ends_with(");") { + break; + } + } + for figure in FIGURES { + if statement.contains(figure) { + offences.push(format!("{name}:{}: {figure}", i + 1)); + } + } + } + } + + assert!( + offences.is_empty(), + "an effective-k is being formatted for output without the bracket that makes it \ + readable:\n {}\n\nPrint a `mirror_provenance::Quotation` instead — it takes a \ + `Bracket` to build, so the range cannot be left behind.", + offences.join("\n ") + ); +} + +/// The one exemption, and the reason it is allowed. +#[test] +fn the_crowd_table_carries_the_bracket() { + let text = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/crowd.rs")) + .expect("crowd.rs must be readable"); + + assert!( + text.contains("unresolved bracket"), + "crowd.rs is exempt from the sweep because its table shows the bracket in its own \ + columns. That column heading is gone, so the exemption no longer holds." + ); + for end in ["b.lower.eff_k_shannon", "b.upper.eff_k_shannon"] { + assert!( + text.contains(end), + "crowd.rs prints an effective-k without {end}, so the table's range is not the \ + bracket it claims to be." + ); + } +} diff --git a/crates/mirror-provenance/src/lib.rs b/crates/mirror-provenance/src/lib.rs index 0822e097..31479bf0 100644 --- a/crates/mirror-provenance/src/lib.rs +++ b/crates/mirror-provenance/src/lib.rs @@ -16,6 +16,7 @@ pub mod facts; pub mod frame; pub mod metrics; pub mod outcome; +pub mod published; pub mod rpc; pub mod trace; @@ -24,7 +25,8 @@ pub use classify::{AnchorSet, Classifier, SetStructure, Thresholds}; pub use edge::{FundingEdge, TransactionView}; pub use facts::{AddressFacts, SigCount}; pub use frame::depositor_of; -pub use metrics::{Anonymity, Bracket}; +pub use metrics::{Anonymity, Bracket, Quotation}; pub use outcome::{Census, Outcome, TerminalRule, Unresolved}; +pub use published::{PublishedHeadline, PUBLISHED_HEADLINE}; pub use rpc::{RpcClient, RpcError}; pub use trace::{classify_sample, Chain, CollectionConfig, Collector, Sample, Scope}; diff --git a/crates/mirror-provenance/src/metrics.rs b/crates/mirror-provenance/src/metrics.rs index e38b59e8..65349b92 100644 --- a/crates/mirror-provenance/src/metrics.rs +++ b/crates/mirror-provenance/src/metrics.rs @@ -234,6 +234,137 @@ impl Bracket { } } +/// A figure in publishable form: the point estimate and, inseparably, the +/// bracket it sits in. +/// +/// This type exists because a bare effective-k is a different kind of number +/// from the one this crate computes. `2^{H(X|C)}` over the *resolved* members +/// answers "how much anonymity survives among the members we could trace", and +/// a reader who lifts that figure out of its bracket has silently upgraded it to +/// "how much anonymity this pool has" — a claim about members nobody traced. +/// Run the tracer longer and the bare number moves; the bracket is what stops +/// that from looking like a finding. +/// +/// So the rendering lives here rather than at each call site. [`Anonymity`] +/// deliberately has no `Display`: there is one way to print one of these +/// figures, it takes a [`Bracket`] to construct, and a caller who wants to show +/// an effective-k has to have the bracket in hand to do it. +#[derive(Debug, Clone, PartialEq)] +pub struct Quotation { + /// Computed over the members that reached a class. + pub point: Anonymity, + /// The two readings of the members that did not. + pub bracket: Bracket, + /// Sampling error over the draw, where a bootstrap was run. Distinct from + /// the bracket, which is about what was not resolved rather than about + /// which members happened to be drawn. + pub sampling: Option, +} + +impl Quotation { + pub fn new(point: Anonymity, bracket: Bracket) -> Self { + Quotation { + point, + bracket, + sampling: None, + } + } + + pub fn with_sampling(mut self, interval: Option) -> Self { + self.sampling = interval; + self + } + + /// Whether the point estimate can carry the argument on its own. + /// + /// Delegates to [`Bracket::is_informative`]: the question is entirely about + /// how much of the set was resolved. + pub fn is_publishable(&self) -> bool { + self.bracket.is_informative() + } +} + +impl std::fmt::Display for Quotation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let a = &self.point; + let b = &self.bracket; + writeln!(f, "resolved members {}", a.nominal_k)?; + writeln!(f, "provenance classes {}", a.classes)?; + writeln!(f)?; + // Every figure below carries its range on the same line as the point. + // Splitting them across a screen is how one of them gets quoted alone. + writeln!( + f, + "loss factor rho {:.4} in {:.4} .. {:.4} <- headline, independent of k", + a.loss_factor, b.lower.loss_factor, b.upper.loss_factor + )?; + writeln!( + f, + "effective-k Shannon {:.4} in {:.4} .. {:.4}", + a.eff_k_shannon, b.lower.eff_k_shannon, b.upper.eff_k_shannon + )?; + writeln!( + f, + "effective-k min-ent {:.4} in {:.4} .. {:.4}", + a.eff_k_min_entropy, b.lower.eff_k_min_entropy, b.upper.eff_k_min_entropy + )?; + writeln!( + f, + " ^ point over the {} resolved; range spans the {} unresolved \ + read as one class and as {} singletons", + b.resolved, b.unresolved, b.unresolved + )?; + writeln!(f)?; + writeln!(f, "leakage Shannon {:.4} bits", a.leakage_shannon_bits)?; + writeln!( + f, + "leakage min-entropy {:.4} bits", + a.leakage_min_entropy_bits + )?; + writeln!(f, "guessing entropy {:.2}", a.guessing_entropy)?; + writeln!(f, "Good-Turing coverage {:.4}", a.good_turing_coverage)?; + writeln!(f, "Chao1 richness {:.2}", a.chao1)?; + writeln!( + f, + "worst-case class {}{}", + a.worst_case, + if a.worst_case_is_informative() { + "" + } else { + " (not informative: under any heavy-tailed prior somebody is always alone)" + } + )?; + + if let Some(i) = &self.sampling { + writeln!(f)?; + writeln!( + f, + "rho under resampling {:.4} .. {:.4} (2.5-97.5%, {} replicates)", + i.lo, i.hi, i.replicates + )?; + writeln!( + f, + " resampling bias {:+.4} (mean {:.4} against a point estimate of {:.4})", + i.resampling_bias(), + i.mean, + i.point + )?; + } + + if !self.is_publishable() { + writeln!(f)?; + write!( + f, + " NOT INFORMATIVE: fewer than half the members reached a class, so the two \ + readings\n diverge and either one quoted alone would describe the sampling \ + budget rather than\n the pool. The point estimates above are reported for \ + completeness, not as a result." + )?; + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -438,6 +569,54 @@ mod tests { assert!(Anonymity::from_class_sizes(&[3, 0, 2]).is_none()); } + /// The rule R2 asks for, enforced by the only renderer there is: an + /// effective-k never appears without the range it sits in. + #[test] + fn a_quotation_never_shows_an_effective_k_without_its_bracket() { + let sizes = [19u64, 1, 1, 1, 1, 1, 1]; + let point = Anonymity::from_class_sizes(&sizes).unwrap(); + let bracket = Bracket::new(&sizes, 8).unwrap(); + let rendered = Quotation::new(point.clone(), bracket.clone()).to_string(); + + for line in rendered.lines() { + if line.contains("effective-k") || line.contains("loss factor") { + assert!( + line.contains(".."), + "a figure was printed without its range: {line}" + ); + } + } + // And the numbers on those lines are this partition's, rather than a + // range of something else that happens to be printed nearby. + for expected in [ + format!("{:.4}", point.eff_k_shannon), + format!("{:.4}", bracket.lower.eff_k_shannon), + format!("{:.4}", bracket.upper.eff_k_shannon), + ] { + assert!( + rendered.contains(&expected), + "{expected} is missing from:\n{rendered}" + ); + } + assert!( + rendered.contains("resolved") && rendered.contains("unresolved"), + "the rendering does not say what the range spans:\n{rendered}" + ); + } + + /// A thin sample renders the refusal rather than a quotable figure. + #[test] + fn a_quotation_over_a_thin_sample_says_so_in_the_output() { + let point = Anonymity::from_class_sizes(&[1, 1]).unwrap(); + let bracket = Bracket::new(&[1, 1], 10).unwrap(); + let q = Quotation::new(point, bracket); + assert!(!q.is_publishable()); + assert!( + q.to_string().contains("NOT INFORMATIVE"), + "a thin sample rendered as if it were a result:\n{q}" + ); + } + #[test] fn labels_group_into_the_same_partition_as_sizes() { let by_label = diff --git a/crates/mirror-provenance/src/published.rs b/crates/mirror-provenance/src/published.rs new file mode 100644 index 00000000..cb2ab663 --- /dev/null +++ b/crates/mirror-provenance/src/published.rs @@ -0,0 +1,153 @@ +//! The measurement this repository publishes, in a form code can quote. +//! +//! Every place that surfaces a pool's *nominal* membership — the CLI's pool +//! status, the settlement summary — is a place a reader can walk away with a `k` +//! that nothing has discounted. The on-chain `k_floor` bounds program-visible +//! membership and nothing else; the effective set is smaller, because an +//! observer can partition members by where their deposit came from. That is not +//! a caveat to keep in a document, so this module puts the measured figure next +//! to the nominal one wherever the nominal one appears. +//! +//! **The numbers below are pinned, not typed.** +//! [`the_published_headline_is_what_the_committed_sample_produces`] recomputes +//! every one of them from the committed sample and fails if they drift. Without +//! that test this file would be a second, unchecked copy of the result — which +//! is the failure mode `docs/MEASUREMENT_LOG.md` already records once. + +/// The headline measurement, and where it came from. +/// +/// Deliberately carries the pool it describes. This measurement is of *another +/// protocol's* live pool, not of `mirror-pool`, which has no depositors — and a +/// figure quoted without that attached is a figure about to be misread as ours. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PublishedHeadline { + /// The committed sample the figures are recomputed from. + pub sample: &'static str, + /// The pool measured, which is not this project's. + pub pool: &'static str, + /// Its address, so a reader can go and look rather than take the name. + pub pool_address: &'static str, + pub attempted: u64, + pub resolved: u64, + /// `ρ = 2^{−H(C)}` over the resolved members. + pub loss_factor: f64, + /// The unresolved bracket around it: unresolved as singletons, and merged. + pub bracket_low: f64, + pub bracket_high: f64, +} + +/// The measurement `README.md` and `docs/MEASUREMENT_LOG.md` publish. +pub const PUBLISHED_HEADLINE: PublishedHeadline = PublishedHeadline { + sample: "data/sample-privacycash-run6.json", + pool: "Privacy Cash", + pool_address: "9fhQBbumKEFuXtMBDw8AaQyAjCorLGJQiS3skWZdQyQD", + attempted: 83, + resolved: 54, + loss_factor: 0.0955, + bracket_low: 0.0350, + bracket_high: 0.1136, +}; + +impl PublishedHeadline { + /// The line to print beside a nominal `k`. + /// + /// Short on purpose: it goes into operational output that a member reads + /// while doing something else, and its whole job is to stop `k_floor` from + /// being read as the anonymity set. The argument is in + /// `docs/PROVENANCE_METHOD.md`; this is the pointer to it. + pub fn note(&self) -> String { + // Hand-wrapped to about 78 columns. This lands in a terminal beside + // other output, and a paragraph that wraps raggedly reads as noise to + // scroll past — which is the one thing it must not be. + format!( + " That k is program-visible membership only, and the effective set is smaller.\n \ + Measured against a live pool of comparable shape — {},\n \ + {} — knowing a member's funding\n \ + class left ρ = {:.4}, inside an unresolved bracket of {:.4} .. {:.4}. Roughly\n \ + an order of magnitude of the nominal figure.\n \ + Method, and what it does not cover: docs/PROVENANCE_METHOD.md.", + self.pool, self.pool_address, self.loss_factor, self.bracket_low, self.bracket_high + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AnchorSet, Anonymity, Bracket, Sample, Thresholds}; + + /// The constants above, recomputed from the committed sample. + /// + /// This is what makes the module a quotation rather than a claim. A figure + /// copied by hand into code drifts from the run that produced it silently, + /// and both copies keep looking right; recomputing means the sample is the + /// only place the number lives. + #[test] + fn the_published_headline_is_what_the_committed_sample_produces() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../data/sample-privacycash-run6.json" + ); + let text = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("the committed sample must be readable: {e}")); + let sample = Sample::from_json(&text).expect("the committed sample must parse"); + + let (results, census) = crate::classify_sample( + &sample, + &AnchorSet::default(), + &Thresholds::default(), + sample.manifest.collected_at, + ); + assert!( + census.may_publish(), + "the sample behind the headline must clear the failure gate" + ); + + let labels: Vec = results + .iter() + .filter_map(|(_, o)| o.label().map(|s| s.to_string())) + .collect(); + let point = Anonymity::from_labels(&labels).expect("the sample must resolve members"); + + let mut sizes: std::collections::BTreeMap<&str, u64> = std::collections::BTreeMap::new(); + for l in &labels { + *sizes.entry(l.as_str()).or_insert(0) += 1; + } + let resolved_sizes: Vec = sizes.into_values().collect(); + let bracket = Bracket::new(&resolved_sizes, census.measurable() - census.resolved) + .expect("the sample must bracket"); + assert!( + bracket.is_informative(), + "the headline must come from a sample that clears the informativeness gate" + ); + + let h = PUBLISHED_HEADLINE; + assert_eq!(h.sample, "data/sample-privacycash-run6.json"); + assert_eq!(point.nominal_k, h.resolved, "resolved members moved"); + assert_eq!( + bracket.resolved + bracket.unresolved, + h.attempted, + "the attempted count moved" + ); + // To the four decimals the documents publish, which is the precision + // the claim is made at. + for (name, got, pinned) in [ + ("rho", point.loss_factor, h.loss_factor), + ("bracket low", bracket.lower.loss_factor, h.bracket_low), + ("bracket high", bracket.upper.loss_factor, h.bracket_high), + ] { + assert!( + (got - pinned).abs() < 5e-5, + "{name} drifted: the sample produces {got:.6}, this module publishes {pinned:.4}" + ); + } + } + + #[test] + fn the_note_names_the_pool_it_measured_and_never_claims_it_is_ours() { + let note = PUBLISHED_HEADLINE.note(); + assert!(note.contains("Privacy Cash"), "{note}"); + assert!(note.contains("program-visible"), "{note}"); + assert!(note.contains("PROVENANCE_METHOD.md"), "{note}"); + } +} diff --git a/data/crowd-result-47000011.json b/data/crowd-result-47000011.json new file mode 100644 index 00000000..119d892c --- /dev/null +++ b/data/crowd-result-47000011.json @@ -0,0 +1,172 @@ +{ + "pool": "HSYsjZc5WTfqbSbW8fKbUQo5QMsFEivhkVDiLNUMWW1r", + "vault": "7j4uJjvVFgZUyEcXuvFaPpQuZF4i7Zq9cSux3ZhUYUkS", + "denomination": 47000011, + "ceiling": { + "divergent": 6, + "uniform": 7, + "divergent_bytes": 1195, + "over_bytes": 1333, + "budget_bytes": 40, + "divergent_through_table": 13, + "uniform_through_table": 18, + "locks_at_divergent_table": 61 + }, + "roster": [ + { + "stake": "o46yK2VURxacvNiqinW5LiDvQnnjHUPdddwtU36jahp", + "vote": "vgcDar2pryHvMgPkKaZfh8pQy4BJxv7SpwUG7zinWjG", + "relay": "HqBHWcuapWCsjJAR8VwNqwZSUGXpwK2L9akhqpbsQxsi" + }, + { + "stake": "2Hte944AoKbb1MU3FeCBuGP9xXw4qeh9WaFWGyyMeAtj", + "vote": "i7NyKBMJCA9bLM2nsGyAGCKHECuR2L5eh4GqFciuwNT", + "relay": "EXqhDLYD1ShrH7mUx22CPL8KtdJEpzhKKus11pLizuyN" + }, + { + "stake": "FNujf4WoRciutdTLNA2naCrMgBy6KhPFGkuD2jzCbTQj", + "vote": "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN", + "relay": "4siJdtmS28nKvjHZRV2a96srHLKkF31uXK2GnX4BPoAq" + }, + { + "stake": "BtUhLNe6Tj8nKUgcfaV6ka2Qkqe3gDcFTxubqhn3awg2", + "vote": "23AoPQc3EPkfLWb14cKiWNahh1H9rtb3UBk8gWseohjF", + "relay": "CsXMfhoboHLQdmLzNt5qu5t9faAepVX6Be4ofzELhZtD" + }, + { + "stake": "ESdQQobWLiMqcz2Zv8zQF99freeVWNDwn2ne6RsoeToK", + "vote": "7AETLyAGJWjp6AWzZqZcP362yv5LQ3nLEdwnXNjdNwwF", + "relay": "8B6gC8zKwY96eHF5VotUpvzPPghRQEHmQW9KL5WnPdcQ" + }, + { + "stake": "CUao9CZtHbL99xmAog5pdC8HMrBFNpj8KjsrX9ScZDgB", + "vote": "2u83Dx5qPV4QnujjJQv8v2SoqG1ixuAxPK5Jwhtkovd1", + "relay": "ByHot4UDnX8WmE5ooB6YDHEWapMEqhQoZdRE691McmL" + } + ], + "signature": "5JY4w8ydCDGbpgdQ8x7uxpEbebbuS3JzB6D6DDoEC6PYH9CsFie8ztYh8ZfGQdGWrQU7r8ZPUHLXcdmrR82GHhxg", + "bytes": 1195, + "compute_units": 141422, + "steps": [ + { + "name": "init_pool", + "signature": "5ktRV78L3S4cnha16oDBX1F7Srh1wovdfAXA5YQwf4tPAWwAwCyqRHpiFMXwazzw5kRzzvit3evaA2f4WhnEPS3n", + "note": "denomination 47000011, k_floor 6", + "slot": 481486468 + }, + { + "name": "deposit", + "signature": "2joAh7qbtKGUR738BvZwhtDdUpW2Uh7DM3JAKfYm269XrUFxKALMqeF5W2xPzDF4AMqc2LeDFBDPf3frADWtTu96", + "note": "note 1", + "slot": 481486474 + }, + { + "name": "deposit", + "signature": "5Utcq8iGn7h2hxr9wHtLArr2mF2e7RzWDCHs28XnXjDdueNPnLVsEzsQN63aXrEHRrfCB6pptCdfoyYj8uEaXLzM", + "note": "note 2", + "slot": 481486479 + }, + { + "name": "deposit", + "signature": "5JeVtsEeNxMctTm7mg1HuLiDmybZbPwkRC46A5kVhz6AHMyGsY1b5rDCwK49rWcwvjAXWRsFYinHnUdozXSggg2k", + "note": "note 3", + "slot": 481486484 + }, + { + "name": "deposit", + "signature": "3GypGAyF8okjBSUqUqgNrH9Kntk2xgyk8UhrgcjErMRfGRgSN7UX566NvQ1X3dY9C6y5yLCYMDAMMxueukXP7bGE", + "note": "note 4", + "slot": 481486489 + }, + { + "name": "deposit", + "signature": "2E11Qf7hxpXoZqUcgm97TLCBK479sij1HaL5JHgUk3Has62DJUfAQhMvq1ekmkWao7PBC5QkYJEPmacnFRESDdQo", + "note": "note 5", + "slot": 481486495 + }, + { + "name": "deposit", + "signature": "63mWmfoJMva8pkrphsqe14BF4NX9Pmv9Gim6dWoqpK6JJVNd1Ts7PxxQ9USvRVP718KEnHbukuaBJ3Qoz36WzKam", + "note": "note 6", + "slot": 481486502 + }, + { + "name": "create stake account", + "signature": "PQZAtfJD25r1HNiH6xudcmnZxLgHF1mBJRJZPnwXX4pFaoLqbmrr3QQtPfTNeMZDXwtD3VUEdWAhAe37kdnUv2H", + "note": "1100000000 lamports, staker = the pool's vault", + "slot": 481486508 + }, + { + "name": "submit_spend", + "signature": "3gGqRTQiz3bUA2SYuNw9BVjVTkmGJWzTxNJYDpNmazRMaEhbPhX3Sr5fi2v4V2fGa4qtRdwTm1fWUWFnJAbQjebf", + "note": "note 0, relay-signed, delegate to vgcDar2pryHvMgPkKaZfh8pQy4BJxv7SpwUG7zinWjG", + "slot": 481486540 + }, + { + "name": "create stake account", + "signature": "5tVbV1rPpPrMUtQx3r7u27Zsi8oBoUD9RsExnRDiP5vBhg25sRePn2LRDGVA8XkNAYNPV61T4mGqyfsLr3qSPADH", + "note": "1100000000 lamports, staker = the pool's vault", + "slot": 481486546 + }, + { + "name": "submit_spend", + "signature": "3Uu5xixZijsUh3bWjjG6Kb2QdRrTaABJs6Y3qVTGVDMq7Dj4Mf7AHNpDcu34WXoaJPyZWVmob9s3EpR6tDaTWLEh", + "note": "note 1, relay-signed, delegate to i7NyKBMJCA9bLM2nsGyAGCKHECuR2L5eh4GqFciuwNT", + "slot": 481486578 + }, + { + "name": "create stake account", + "signature": "kgGHhhSXbYH5E8x4QJsGN92TVoLL7oY9q3ToDkqRjgrQVPMZSjA4VGgA1SzCcTrz8hrMEUmkDN6oXzUdyDdjM56", + "note": "1100000000 lamports, staker = the pool's vault", + "slot": 481486584 + }, + { + "name": "submit_spend", + "signature": "5BGA7DnFbLcrKEF7ihMgvGahHZonA1vxQkEEdEW3tr5w2dQuTMuULCKn4PYJpMjeJyAUoUT1CsjKRJZSL8kZ8Npp", + "note": "note 2, relay-signed, delegate to 5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN", + "slot": 481486616 + }, + { + "name": "create stake account", + "signature": "yRcgWcHU9gcQDp273sGwijgb7GqKJznPY7ekkERQLQBpsvS5w73uDy9yUj1QJixYBJrVV2htbGe7WPpDuCB22re", + "note": "1100000000 lamports, staker = the pool's vault", + "slot": 481486622 + }, + { + "name": "submit_spend", + "signature": "4jxdCPTCk2niK96wYPUyveHmAV7KW4evzD1gPuuNnKNNWZgVQwKNpCyUDJ8fn7n9ZVV9fxu4KsoQQeM4eQbCaLZ", + "note": "note 3, relay-signed, delegate to 23AoPQc3EPkfLWb14cKiWNahh1H9rtb3UBk8gWseohjF", + "slot": 481486654 + }, + { + "name": "create stake account", + "signature": "4hwcy4SogdqBU2h17dgdtbsAoSqCNw1Xv5V3RdZbzaWWiVXF4W2f5JUru921ZjVEnF8rAoEtoJtD7azejzh2gVb3", + "note": "1100000000 lamports, staker = the pool's vault", + "slot": 481486660 + }, + { + "name": "submit_spend", + "signature": "4Z8W9zM9cqtjyXUea7P92z64d6enLF3qoS9TTY7jFXpArTJEL2uspqVd5gBR6Y7aXWAg4WCweHmG7NPQWrDbUiy4", + "note": "note 4, relay-signed, delegate to 7AETLyAGJWjp6AWzZqZcP362yv5LQ3nLEdwnXNjdNwwF", + "slot": 481486691 + }, + { + "name": "create stake account", + "signature": "5moUKFfBAdCYQnkSoePvg4LgH98jPxC9n8fEd1i1zvH7XoE3vatptN2aHBhYj32uay5RSGK8UrF3hQR5CJG98uTR", + "note": "1100000000 lamports, staker = the pool's vault", + "slot": 481486697 + }, + { + "name": "submit_spend", + "signature": "3TTRoeAMqYWxjUZPay3wfMEiDPa1homVqugr84A5mkXLL7Agb3mv3iYp7wGZUWDpNRVZn5Z67ZvWbSnLU3kLPdR2", + "note": "note 5, relay-signed, delegate to 2u83Dx5qPV4QnujjJQv8v2SoqG1ixuAxPK5Jwhtkovd1", + "slot": 481486729 + }, + { + "name": "settle_epoch", + "signature": "5JY4w8ydCDGbpgdQ8x7uxpEbebbuS3JzB6D6DDoEC6PYH9CsFie8ztYh8ZfGQdGWrQU7r8ZPUHLXcdmrR82GHhxg", + "note": "6 delegations to 6 different validators, one transaction, 1195 bytes", + "slot": 481486735 + } + ] +} \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d31c1cf9..2083bfa7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -209,9 +209,17 @@ settled in the same transaction as three plain transfers and a memo. timestamp and an ordering. 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 is a -liveness hazard — a quiet pool could hold a member's funds until a crowd that -never comes. Dropping it makes "synchronised" a word rather than a property. +in it must have waited out the pool's timeout — set at creation, an hour by +default. Requiring the crowd unconditionally is a liveness hazard — a quiet pool +could hold a member's funds until a crowd that never comes. Dropping it makes +"synchronised" a word rather than a property. + +The timeout half is opt-in and marked. A settler who wants an under-floor batch +passes `allow_below_floor`, and without it the program refuses rather than +settling; the settlement that does land logs the count it carried against the +floor it missed. Neither closes the hole — `THREAT_MODEL.md` argues why it stays +open — but a batch that costs its members their crowd is now something somebody +asked for and something the chain records. ### The permissionless exit diff --git a/docs/CROWD.md b/docs/CROWD.md index 691b5a06..5296ecc5 100644 --- a/docs/CROWD.md +++ b/docs/CROWD.md @@ -4,18 +4,18 @@ A batch whose members all do the same thing is the easy case for an anonymity se Validator choice is the behavioural pattern worth hiding. It is stable, it is public, and it fingerprints a staker across epochs far more reliably than an amount does. What an observer gets from the transaction below is a set of delegations landing at one timestamp with no way to say which member asked for which. -Generated by `mirror crowd`. Pool [`Cqg4gj4zwHZGfp1P2v6j6pB4dLbWgsL1vJB7YkjsFWAK`](https://explorer.solana.com/address/Cqg4gj4zwHZGfp1P2v6j6pB4dLbWgsL1vJB7YkjsFWAK?cluster=devnet), vault [`6NrJ3id157J7qMi5veukujtvX6T5rJz1WZT1m6gd8eMZ`](https://explorer.solana.com/address/6NrJ3id157J7qMi5veukujtvX6T5rJz1WZT1m6gd8eMZ?cluster=devnet), denomination 43000007 lamports. +Generated by `mirror crowd`. Pool [`HSYsjZc5WTfqbSbW8fKbUQo5QMsFEivhkVDiLNUMWW1r`](https://explorer.solana.com/address/HSYsjZc5WTfqbSbW8fKbUQo5QMsFEivhkVDiLNUMWW1r?cluster=devnet), vault [`7j4uJjvVFgZUyEcXuvFaPpQuZF4i7Zq9cSux3ZhUYUkS`](https://explorer.solana.com/address/7j4uJjvVFgZUyEcXuvFaPpQuZF4i7Zq9cSux3ZhUYUkS?cluster=devnet), denomination 47000011 lamports. ## The settlement -[`5SqtqpuJw3pAoBMMB9DmXaqFr93rWsnqWMyacRZ3ZpP5eQpacLTAjrYugAW2ni3PMucAzDLJ4TsLZU3aQd3ZVMPZ`](https://explorer.solana.com/tx/5SqtqpuJw3pAoBMMB9DmXaqFr93rWsnqWMyacRZ3ZpP5eQpacLTAjrYugAW2ni3PMucAzDLJ4TsLZU3aQd3ZVMPZ?cluster=devnet) +[`5JY4w8ydCDGbpgdQ8x7uxpEbebbuS3JzB6D6DDoEC6PYH9CsFie8ztYh8ZfGQdGWrQU7r8ZPUHLXcdmrR82GHhxg`](https://explorer.solana.com/tx/5JY4w8ydCDGbpgdQ8x7uxpEbebbuS3JzB6D6DDoEC6PYH9CsFie8ztYh8ZfGQdGWrQU7r8ZPUHLXcdmrR82GHhxg?cluster=devnet) | | | |---|---| | members | 6 | | distinct validators | 6 | -| wire size | 1194 of 1232 bytes | -| compute | 142856 of 200000 CU | +| wire size | 1195 of 1232 bytes | +| compute | 141422 of 200000 CU | ## Who got what @@ -25,12 +25,12 @@ Every member has their own relay, and no relay key appears twice. That is the wo | member | stake account | delegated to | relay | |---|---|---|---| -| 0 | [`5P9AHY2tGoQ9xedLeyvC5WsU4FXxxH8LgtC2kzRUMZtT`](https://explorer.solana.com/address/5P9AHY2tGoQ9xedLeyvC5WsU4FXxxH8LgtC2kzRUMZtT?cluster=devnet) | [`vgcDar2pryHvMgPkKaZfh8pQy4BJxv7SpwUG7zinWjG`](https://explorer.solana.com/address/vgcDar2pryHvMgPkKaZfh8pQy4BJxv7SpwUG7zinWjG?cluster=devnet) | [`7GC9vjEJTUk1d3DLfb9ufBuSo4ooU5HzAmzeGdFejtBX`](https://explorer.solana.com/address/7GC9vjEJTUk1d3DLfb9ufBuSo4ooU5HzAmzeGdFejtBX?cluster=devnet) | -| 1 | [`DrrzXbAmLdgLfmcGtN1mAjgEuRK5mPyRSVcwazUtUWaT`](https://explorer.solana.com/address/DrrzXbAmLdgLfmcGtN1mAjgEuRK5mPyRSVcwazUtUWaT?cluster=devnet) | [`i7NyKBMJCA9bLM2nsGyAGCKHECuR2L5eh4GqFciuwNT`](https://explorer.solana.com/address/i7NyKBMJCA9bLM2nsGyAGCKHECuR2L5eh4GqFciuwNT?cluster=devnet) | [`CDNhhfH3qspD6Bgwsd6i7T6EGjE3r7yWPGBwaHyi5TXM`](https://explorer.solana.com/address/CDNhhfH3qspD6Bgwsd6i7T6EGjE3r7yWPGBwaHyi5TXM?cluster=devnet) | -| 2 | [`EgGXn9y2ea9bDqGoUpU6Z8ET2JNm6zNaFu1Ux2x6RNNx`](https://explorer.solana.com/address/EgGXn9y2ea9bDqGoUpU6Z8ET2JNm6zNaFu1Ux2x6RNNx?cluster=devnet) | [`5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN`](https://explorer.solana.com/address/5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN?cluster=devnet) | [`2UWeNWf8yg3UPbeycv6SMoKLjmbtD2XgAX8wiQ92RouF`](https://explorer.solana.com/address/2UWeNWf8yg3UPbeycv6SMoKLjmbtD2XgAX8wiQ92RouF?cluster=devnet) | -| 3 | [`FgWdYn2gN9oUxN18NJ958BkgR5zoCFagWp73pQ1YHc9x`](https://explorer.solana.com/address/FgWdYn2gN9oUxN18NJ958BkgR5zoCFagWp73pQ1YHc9x?cluster=devnet) | [`23AoPQc3EPkfLWb14cKiWNahh1H9rtb3UBk8gWseohjF`](https://explorer.solana.com/address/23AoPQc3EPkfLWb14cKiWNahh1H9rtb3UBk8gWseohjF?cluster=devnet) | [`D1oCaiaGCJGyi2Co7mE7W5nMpcATdDnGtGMSGrk8Qcpw`](https://explorer.solana.com/address/D1oCaiaGCJGyi2Co7mE7W5nMpcATdDnGtGMSGrk8Qcpw?cluster=devnet) | -| 4 | [`Fa8khDvR3arVezDmDZpLPHA4VVwRuSwzfJ2gcDSKQmDo`](https://explorer.solana.com/address/Fa8khDvR3arVezDmDZpLPHA4VVwRuSwzfJ2gcDSKQmDo?cluster=devnet) | [`7AETLyAGJWjp6AWzZqZcP362yv5LQ3nLEdwnXNjdNwwF`](https://explorer.solana.com/address/7AETLyAGJWjp6AWzZqZcP362yv5LQ3nLEdwnXNjdNwwF?cluster=devnet) | [`DtoAEYygmCWw156gn4DB1ZCUTJ9szUbT7mLxqNs5bTos`](https://explorer.solana.com/address/DtoAEYygmCWw156gn4DB1ZCUTJ9szUbT7mLxqNs5bTos?cluster=devnet) | -| 5 | [`B5unNLodBUxcsggqV4g1KzoyAXz4oNHN3bEnRTJ1Fmkw`](https://explorer.solana.com/address/B5unNLodBUxcsggqV4g1KzoyAXz4oNHN3bEnRTJ1Fmkw?cluster=devnet) | [`2u83Dx5qPV4QnujjJQv8v2SoqG1ixuAxPK5Jwhtkovd1`](https://explorer.solana.com/address/2u83Dx5qPV4QnujjJQv8v2SoqG1ixuAxPK5Jwhtkovd1?cluster=devnet) | [`3dYe9vTAVYpsrSYtxLRrawFYQaMNQsLhqhscmFhRKobY`](https://explorer.solana.com/address/3dYe9vTAVYpsrSYtxLRrawFYQaMNQsLhqhscmFhRKobY?cluster=devnet) | +| 0 | [`o46yK2VURxacvNiqinW5LiDvQnnjHUPdddwtU36jahp`](https://explorer.solana.com/address/o46yK2VURxacvNiqinW5LiDvQnnjHUPdddwtU36jahp?cluster=devnet) | [`vgcDar2pryHvMgPkKaZfh8pQy4BJxv7SpwUG7zinWjG`](https://explorer.solana.com/address/vgcDar2pryHvMgPkKaZfh8pQy4BJxv7SpwUG7zinWjG?cluster=devnet) | [`HqBHWcuapWCsjJAR8VwNqwZSUGXpwK2L9akhqpbsQxsi`](https://explorer.solana.com/address/HqBHWcuapWCsjJAR8VwNqwZSUGXpwK2L9akhqpbsQxsi?cluster=devnet) | +| 1 | [`2Hte944AoKbb1MU3FeCBuGP9xXw4qeh9WaFWGyyMeAtj`](https://explorer.solana.com/address/2Hte944AoKbb1MU3FeCBuGP9xXw4qeh9WaFWGyyMeAtj?cluster=devnet) | [`i7NyKBMJCA9bLM2nsGyAGCKHECuR2L5eh4GqFciuwNT`](https://explorer.solana.com/address/i7NyKBMJCA9bLM2nsGyAGCKHECuR2L5eh4GqFciuwNT?cluster=devnet) | [`EXqhDLYD1ShrH7mUx22CPL8KtdJEpzhKKus11pLizuyN`](https://explorer.solana.com/address/EXqhDLYD1ShrH7mUx22CPL8KtdJEpzhKKus11pLizuyN?cluster=devnet) | +| 2 | [`FNujf4WoRciutdTLNA2naCrMgBy6KhPFGkuD2jzCbTQj`](https://explorer.solana.com/address/FNujf4WoRciutdTLNA2naCrMgBy6KhPFGkuD2jzCbTQj?cluster=devnet) | [`5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN`](https://explorer.solana.com/address/5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN?cluster=devnet) | [`4siJdtmS28nKvjHZRV2a96srHLKkF31uXK2GnX4BPoAq`](https://explorer.solana.com/address/4siJdtmS28nKvjHZRV2a96srHLKkF31uXK2GnX4BPoAq?cluster=devnet) | +| 3 | [`BtUhLNe6Tj8nKUgcfaV6ka2Qkqe3gDcFTxubqhn3awg2`](https://explorer.solana.com/address/BtUhLNe6Tj8nKUgcfaV6ka2Qkqe3gDcFTxubqhn3awg2?cluster=devnet) | [`23AoPQc3EPkfLWb14cKiWNahh1H9rtb3UBk8gWseohjF`](https://explorer.solana.com/address/23AoPQc3EPkfLWb14cKiWNahh1H9rtb3UBk8gWseohjF?cluster=devnet) | [`CsXMfhoboHLQdmLzNt5qu5t9faAepVX6Be4ofzELhZtD`](https://explorer.solana.com/address/CsXMfhoboHLQdmLzNt5qu5t9faAepVX6Be4ofzELhZtD?cluster=devnet) | +| 4 | [`ESdQQobWLiMqcz2Zv8zQF99freeVWNDwn2ne6RsoeToK`](https://explorer.solana.com/address/ESdQQobWLiMqcz2Zv8zQF99freeVWNDwn2ne6RsoeToK?cluster=devnet) | [`7AETLyAGJWjp6AWzZqZcP362yv5LQ3nLEdwnXNjdNwwF`](https://explorer.solana.com/address/7AETLyAGJWjp6AWzZqZcP362yv5LQ3nLEdwnXNjdNwwF?cluster=devnet) | [`8B6gC8zKwY96eHF5VotUpvzPPghRQEHmQW9KL5WnPdcQ`](https://explorer.solana.com/address/8B6gC8zKwY96eHF5VotUpvzPPghRQEHmQW9KL5WnPdcQ?cluster=devnet) | +| 5 | [`CUao9CZtHbL99xmAog5pdC8HMrBFNpj8KjsrX9ScZDgB`](https://explorer.solana.com/address/CUao9CZtHbL99xmAog5pdC8HMrBFNpj8KjsrX9ScZDgB?cluster=devnet) | [`2u83Dx5qPV4QnujjJQv8v2SoqG1ixuAxPK5Jwhtkovd1`](https://explorer.solana.com/address/2u83Dx5qPV4QnujjJQv8v2SoqG1ixuAxPK5Jwhtkovd1?cluster=devnet) | [`ByHot4UDnX8WmE5ooB6YDHEWapMEqhQoZdRE691McmL`](https://explorer.solana.com/address/ByHot4UDnX8WmE5ooB6YDHEWapMEqhQoZdRE691McmL?cluster=devnet) | ## What divergence costs @@ -41,17 +41,17 @@ Divergence is not free, and the price is anonymity-set size. A transaction names | all delegating to the same validator | 7 | | each delegating to a different one | 6 | -At 6 members the settlement weighs 1194 bytes with 38 to spare; one more member weighs 1332 bytes, 100 over the limit. Both figures come from serializing the real instruction, and the settlement that landed above is 1194 bytes — the same number, which is what makes the measurement a prediction rather than a description. +At 6 members the settlement weighs 1195 bytes with 37 to spare; one more member weighs 1333 bytes, 101 over the limit. Both figures come from serializing the real instruction, and the settlement that landed above is 1195 bytes — the same number, which is what makes the measurement a prediction rather than a description. ## And compute, which is closer than it is for payments -This settlement burned **142856 CU of the 200000** a single instruction gets by default — 71% of the budget, for 6 payouts and 6 cross-program invocations. That is a different regime from a batch of plain transfers: `ten_spends_fit_in_one_settlement_and_the_packet_is_what_stops_the_eleventh` settles ten of those in 19,545 CU, where compute is nowhere in the conversation. A delegation costs roughly an order of magnitude more per member than a payment does. +This settlement burned **141422 CU of the 200000** a single instruction gets by default — 71% of the budget, for 6 payouts and 6 cross-program invocations. That is a different regime from a batch of plain transfers: `ten_spends_fit_in_one_settlement_and_the_packet_is_what_stops_the_eleventh` settles ten of those in 19,545 CU, where compute is nowhere in the conversation. A delegation costs roughly an order of magnitude more per member than a payment does. Each member's Groth16 proof was verified earlier, in their own `submit_spend`, which costs about 101,000 CU. That is why the two phases exist: verifying 6 proofs here would cost over 600,000 CU — comfortably past the 200,000 a single instruction gets by default, and a large fraction of the 1.4M a whole transaction may ever request. -The packet binds first — 6 members is where the bytes run out, and the budget is not exhausted there — but for a **legacy** transaction the two limits are barely independent, and that is worth stating plainly. The usual answer to a settlement that runs out of compute is to ask for more with a `SetComputeUnitLimit` instruction. Measured against this very batch, that instruction costs **40 bytes**, and a full legacy settlement has 38 to spare. In a legacy transaction, raising the budget means dropping a member. +The packet binds first — 6 members is where the bytes run out, and the budget is not exhausted there — but for a **legacy** transaction the two limits are barely independent, and that is worth stating plainly. The usual answer to a settlement that runs out of compute is to ask for more with a `SetComputeUnitLimit` instruction. Measured against this very batch, that instruction costs **40 bytes**, and a full legacy settlement has 37 to spare. In a legacy transaction, raising the budget means dropping a member. -**A lookup table lifts that, and here is how far.** Naming accounts by one byte each takes the packet out of the way — `mirror settle` does it automatically, and a batch of twenty plain transfers settled that way on devnet at 332 bytes of 1232. What takes over for *delegations* is the 64-account lock limit, and it is a different kind of limit: bytes are spent naming an account, locks are held per **distinct** account. +**A lookup table lifts that, and here is how far.** Naming accounts by one byte each takes the packet out of the way — `mirror settle` does it automatically, and a batch of twenty plain transfers settled that way on devnet at 333 bytes of 1232. What takes over for *delegations* is the 64-account lock limit, and it is a different kind of limit: bytes are spent naming an account, locks are held per **distinct** account. | batch | legacy packet | through a lookup table | |---|---|---| @@ -66,32 +66,34 @@ A full divergent batch through a table holds 61 of the 64 locks a transaction ma ## Every step -| step | signature | note | -|---|---|---| -| init_pool | [`5dPVS1efe2JcqJxV26Lxk5QwnTdsy8qfxc6wbFJXKNM9ES16qx6gG7P53c9RLnuMkzswjM9yoW4JEScrwYYg4HSN`](https://explorer.solana.com/tx/5dPVS1efe2JcqJxV26Lxk5QwnTdsy8qfxc6wbFJXKNM9ES16qx6gG7P53c9RLnuMkzswjM9yoW4JEScrwYYg4HSN?cluster=devnet) | denomination 43000007, k_floor 6 | -| deposit | [`4eTFg35LP21D3HCQYn2DE7WExrhNZsmyfh39i7GMYe9bxfsyEhdJJ2STDt2c6SmaeEGAB1whQbRtqbr71kRoMfDr`](https://explorer.solana.com/tx/4eTFg35LP21D3HCQYn2DE7WExrhNZsmyfh39i7GMYe9bxfsyEhdJJ2STDt2c6SmaeEGAB1whQbRtqbr71kRoMfDr?cluster=devnet) | note 1 | -| deposit | [`2BqmBu6anyCMhyvexeVsuH9zLB847bW1xBNndxeidCU7FYma3VyxcqTUEmRRVvJgH95MtTzsuEbwcRuxD4K25VQm`](https://explorer.solana.com/tx/2BqmBu6anyCMhyvexeVsuH9zLB847bW1xBNndxeidCU7FYma3VyxcqTUEmRRVvJgH95MtTzsuEbwcRuxD4K25VQm?cluster=devnet) | note 2 | -| deposit | [`7RwN4jc54ioVS7Y8m8p9PKGnU2vPrwPpK7jRZ4RvfmF4TWCniBxzNrxzbXpkFt3R6VWhfKnvP4d5sypGrCCXq2E`](https://explorer.solana.com/tx/7RwN4jc54ioVS7Y8m8p9PKGnU2vPrwPpK7jRZ4RvfmF4TWCniBxzNrxzbXpkFt3R6VWhfKnvP4d5sypGrCCXq2E?cluster=devnet) | note 3 | -| deposit | [`3drHxm9YgHi4VUy45HRVbmtZQe76oaPF7dxYWh6VAXKiJhUG7uBscPuDhmZVzRdnmFHtvg72PR9qwMU4RizpKPiE`](https://explorer.solana.com/tx/3drHxm9YgHi4VUy45HRVbmtZQe76oaPF7dxYWh6VAXKiJhUG7uBscPuDhmZVzRdnmFHtvg72PR9qwMU4RizpKPiE?cluster=devnet) | note 4 | -| deposit | [`2YB3RHGPCu3cY3tXjnRkFisd7q2JYZUnYB2z8hC11FHbX5YAsGjSiAs7BJbGTg4JHXE3FVwSVpz9t9NFevdQ5Gxh`](https://explorer.solana.com/tx/2YB3RHGPCu3cY3tXjnRkFisd7q2JYZUnYB2z8hC11FHbX5YAsGjSiAs7BJbGTg4JHXE3FVwSVpz9t9NFevdQ5Gxh?cluster=devnet) | note 5 | -| deposit | [`2UHgtUdLYJQmqBXcJWPJPK7VM6uBLKqaZdcMucXWoUUVpYV4Cnk8ox1jMUSZx9DJk6Xp6EKEQtxL9WLdWP4jT2ZL`](https://explorer.solana.com/tx/2UHgtUdLYJQmqBXcJWPJPK7VM6uBLKqaZdcMucXWoUUVpYV4Cnk8ox1jMUSZx9DJk6Xp6EKEQtxL9WLdWP4jT2ZL?cluster=devnet) | note 6 | -| create stake account | [`28djx5Y9hNmg58XbTzd4if4N6H2kZUwfREBKtdrCsFnV6Gu6bb6ptrGtMzL6VoZnVYSFKRXQTDNcKHdLUiQoGHwq`](https://explorer.solana.com/tx/28djx5Y9hNmg58XbTzd4if4N6H2kZUwfREBKtdrCsFnV6Gu6bb6ptrGtMzL6VoZnVYSFKRXQTDNcKHdLUiQoGHwq?cluster=devnet) | 1100000000 lamports, staker = the pool's vault | -| submit_spend | [`5D6VC6czzJ79fzuvvXuNHX5xcV5p7Gbv6hxDwHzgt5QtHHKScu11hYSBkdPPe7kQoUhhQEBoyiDPzjWGGVnroyFT`](https://explorer.solana.com/tx/5D6VC6czzJ79fzuvvXuNHX5xcV5p7Gbv6hxDwHzgt5QtHHKScu11hYSBkdPPe7kQoUhhQEBoyiDPzjWGGVnroyFT?cluster=devnet) | note 0, relay-signed, delegate to vgcDar2pryHvMgPkKaZfh8pQy4BJxv7SpwUG7zinWjG | -| create stake account | [`2Tv4CZSFD8wxpuhiihsw2xEd53bxTE7i6ka1dpZvzhcfDqXxdCL8k1jydgPCjJ7pUuL4CuPHP11BnJHPqjsawK6V`](https://explorer.solana.com/tx/2Tv4CZSFD8wxpuhiihsw2xEd53bxTE7i6ka1dpZvzhcfDqXxdCL8k1jydgPCjJ7pUuL4CuPHP11BnJHPqjsawK6V?cluster=devnet) | 1100000000 lamports, staker = the pool's vault | -| submit_spend | [`4gpxQ7UzGNKBwft1Y8dmDszs2jRz1S1Q2RZsFkLf4AjmyY1B3YNSyPoLPA5uqbRonWx2SkYZCYFtyS5AZN2EKsGK`](https://explorer.solana.com/tx/4gpxQ7UzGNKBwft1Y8dmDszs2jRz1S1Q2RZsFkLf4AjmyY1B3YNSyPoLPA5uqbRonWx2SkYZCYFtyS5AZN2EKsGK?cluster=devnet) | note 1, relay-signed, delegate to i7NyKBMJCA9bLM2nsGyAGCKHECuR2L5eh4GqFciuwNT | -| create stake account | [`3znGocR1LKMkffXQ1yHm9BKfhbM1o4Y6r22J3vyKjGLK76fAfArMxe7CeoP8sk4umStTt7f7EtSKbe29zaz4iges`](https://explorer.solana.com/tx/3znGocR1LKMkffXQ1yHm9BKfhbM1o4Y6r22J3vyKjGLK76fAfArMxe7CeoP8sk4umStTt7f7EtSKbe29zaz4iges?cluster=devnet) | 1100000000 lamports, staker = the pool's vault | -| submit_spend | [`2YBakbrJy1CXTaJpkxwAr54EW5PdWytG4P2ycJqFyRx48tdKKMqMuejEjS9JaMGmxGHKY9uiJ623tjb8ErDUFHS1`](https://explorer.solana.com/tx/2YBakbrJy1CXTaJpkxwAr54EW5PdWytG4P2ycJqFyRx48tdKKMqMuejEjS9JaMGmxGHKY9uiJ623tjb8ErDUFHS1?cluster=devnet) | note 2, relay-signed, delegate to 5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN | -| create stake account | [`MhASpmqysCWXNrS5B36RVopqKnQKFcSRanQBd1nueD2bCck4SQBvJEqYy98ajhb9JC8UcvvncjRQcghnDyXYqzf`](https://explorer.solana.com/tx/MhASpmqysCWXNrS5B36RVopqKnQKFcSRanQBd1nueD2bCck4SQBvJEqYy98ajhb9JC8UcvvncjRQcghnDyXYqzf?cluster=devnet) | 1100000000 lamports, staker = the pool's vault | -| submit_spend | [`uEZXvq615hGxgeKKYmrpP4YPp5B3xygRUNAgZ4ddCH47q6NypgQqR49B8U1vMB9XVWnZQaPjqcqbV1JeQgAKFvN`](https://explorer.solana.com/tx/uEZXvq615hGxgeKKYmrpP4YPp5B3xygRUNAgZ4ddCH47q6NypgQqR49B8U1vMB9XVWnZQaPjqcqbV1JeQgAKFvN?cluster=devnet) | note 3, relay-signed, delegate to 23AoPQc3EPkfLWb14cKiWNahh1H9rtb3UBk8gWseohjF | -| create stake account | [`3JYyJ1ZnuGUdX888cV4zkqmhqTd42zE8QAbo3x8jJ6bfnC4MZT2RkiQkLHnqtjkNJmTQSAeQDLbEVwWRXaMmS5ES`](https://explorer.solana.com/tx/3JYyJ1ZnuGUdX888cV4zkqmhqTd42zE8QAbo3x8jJ6bfnC4MZT2RkiQkLHnqtjkNJmTQSAeQDLbEVwWRXaMmS5ES?cluster=devnet) | 1100000000 lamports, staker = the pool's vault | -| submit_spend | [`5KE1wc2VZh6GTLiiVNQ8Xad92hHoKNARZ8AEWQvwJeBsfLJ93Cc6GkvVtieTdwCh5ZjPcDTwk31Jjzwshtemr7XN`](https://explorer.solana.com/tx/5KE1wc2VZh6GTLiiVNQ8Xad92hHoKNARZ8AEWQvwJeBsfLJ93Cc6GkvVtieTdwCh5ZjPcDTwk31Jjzwshtemr7XN?cluster=devnet) | note 4, relay-signed, delegate to 7AETLyAGJWjp6AWzZqZcP362yv5LQ3nLEdwnXNjdNwwF | -| create stake account | [`2fRNA4wLYzoJceT2HmAXEPrvK1Sv1haqfRJK7vDDcyKCLQPa65ebcE9u6Lm8DZbdgfWm2LiaS5nDUzvxaCNrwecE`](https://explorer.solana.com/tx/2fRNA4wLYzoJceT2HmAXEPrvK1Sv1haqfRJK7vDDcyKCLQPa65ebcE9u6Lm8DZbdgfWm2LiaS5nDUzvxaCNrwecE?cluster=devnet) | 1100000000 lamports, staker = the pool's vault | -| submit_spend | [`4haLJna7fmp85ahKppvXR8bVgGSFmhsftxy1XEfuztZ8mKVKxdVzdwLVwS6CedD2fmzdMwPWc5Na2DAaNvUZdgQM`](https://explorer.solana.com/tx/4haLJna7fmp85ahKppvXR8bVgGSFmhsftxy1XEfuztZ8mKVKxdVzdwLVwS6CedD2fmzdMwPWc5Na2DAaNvUZdgQM?cluster=devnet) | note 5, relay-signed, delegate to 2u83Dx5qPV4QnujjJQv8v2SoqG1ixuAxPK5Jwhtkovd1 | -| settle_epoch | [`5SqtqpuJw3pAoBMMB9DmXaqFr93rWsnqWMyacRZ3ZpP5eQpacLTAjrYugAW2ni3PMucAzDLJ4TsLZU3aQd3ZVMPZ`](https://explorer.solana.com/tx/5SqtqpuJw3pAoBMMB9DmXaqFr93rWsnqWMyacRZ3ZpP5eQpacLTAjrYugAW2ni3PMucAzDLJ4TsLZU3aQd3ZVMPZ?cluster=devnet) | 6 delegations to 6 different validators, one transaction, 1194 bytes | +| step | signature | slot | note | +|---|---|---|---| +| init_pool | [`5ktRV78L3S4cnha16oDBX1F7Srh1wovdfAXA5YQwf4tPAWwAwCyqRHpiFMXwazzw5kRzzvit3evaA2f4WhnEPS3n`](https://explorer.solana.com/tx/5ktRV78L3S4cnha16oDBX1F7Srh1wovdfAXA5YQwf4tPAWwAwCyqRHpiFMXwazzw5kRzzvit3evaA2f4WhnEPS3n?cluster=devnet) | 481486468 | denomination 47000011, k_floor 6 | +| deposit | [`2joAh7qbtKGUR738BvZwhtDdUpW2Uh7DM3JAKfYm269XrUFxKALMqeF5W2xPzDF4AMqc2LeDFBDPf3frADWtTu96`](https://explorer.solana.com/tx/2joAh7qbtKGUR738BvZwhtDdUpW2Uh7DM3JAKfYm269XrUFxKALMqeF5W2xPzDF4AMqc2LeDFBDPf3frADWtTu96?cluster=devnet) | 481486474 | note 1 | +| deposit | [`5Utcq8iGn7h2hxr9wHtLArr2mF2e7RzWDCHs28XnXjDdueNPnLVsEzsQN63aXrEHRrfCB6pptCdfoyYj8uEaXLzM`](https://explorer.solana.com/tx/5Utcq8iGn7h2hxr9wHtLArr2mF2e7RzWDCHs28XnXjDdueNPnLVsEzsQN63aXrEHRrfCB6pptCdfoyYj8uEaXLzM?cluster=devnet) | 481486479 | note 2 | +| deposit | [`5JeVtsEeNxMctTm7mg1HuLiDmybZbPwkRC46A5kVhz6AHMyGsY1b5rDCwK49rWcwvjAXWRsFYinHnUdozXSggg2k`](https://explorer.solana.com/tx/5JeVtsEeNxMctTm7mg1HuLiDmybZbPwkRC46A5kVhz6AHMyGsY1b5rDCwK49rWcwvjAXWRsFYinHnUdozXSggg2k?cluster=devnet) | 481486484 | note 3 | +| deposit | [`3GypGAyF8okjBSUqUqgNrH9Kntk2xgyk8UhrgcjErMRfGRgSN7UX566NvQ1X3dY9C6y5yLCYMDAMMxueukXP7bGE`](https://explorer.solana.com/tx/3GypGAyF8okjBSUqUqgNrH9Kntk2xgyk8UhrgcjErMRfGRgSN7UX566NvQ1X3dY9C6y5yLCYMDAMMxueukXP7bGE?cluster=devnet) | 481486489 | note 4 | +| deposit | [`2E11Qf7hxpXoZqUcgm97TLCBK479sij1HaL5JHgUk3Has62DJUfAQhMvq1ekmkWao7PBC5QkYJEPmacnFRESDdQo`](https://explorer.solana.com/tx/2E11Qf7hxpXoZqUcgm97TLCBK479sij1HaL5JHgUk3Has62DJUfAQhMvq1ekmkWao7PBC5QkYJEPmacnFRESDdQo?cluster=devnet) | 481486495 | note 5 | +| deposit | [`63mWmfoJMva8pkrphsqe14BF4NX9Pmv9Gim6dWoqpK6JJVNd1Ts7PxxQ9USvRVP718KEnHbukuaBJ3Qoz36WzKam`](https://explorer.solana.com/tx/63mWmfoJMva8pkrphsqe14BF4NX9Pmv9Gim6dWoqpK6JJVNd1Ts7PxxQ9USvRVP718KEnHbukuaBJ3Qoz36WzKam?cluster=devnet) | 481486502 | note 6 | +| create stake account | [`PQZAtfJD25r1HNiH6xudcmnZxLgHF1mBJRJZPnwXX4pFaoLqbmrr3QQtPfTNeMZDXwtD3VUEdWAhAe37kdnUv2H`](https://explorer.solana.com/tx/PQZAtfJD25r1HNiH6xudcmnZxLgHF1mBJRJZPnwXX4pFaoLqbmrr3QQtPfTNeMZDXwtD3VUEdWAhAe37kdnUv2H?cluster=devnet) | 481486508 | 1100000000 lamports, staker = the pool's vault | +| submit_spend | [`3gGqRTQiz3bUA2SYuNw9BVjVTkmGJWzTxNJYDpNmazRMaEhbPhX3Sr5fi2v4V2fGa4qtRdwTm1fWUWFnJAbQjebf`](https://explorer.solana.com/tx/3gGqRTQiz3bUA2SYuNw9BVjVTkmGJWzTxNJYDpNmazRMaEhbPhX3Sr5fi2v4V2fGa4qtRdwTm1fWUWFnJAbQjebf?cluster=devnet) | 481486540 | note 0, relay-signed, delegate to vgcDar2pryHvMgPkKaZfh8pQy4BJxv7SpwUG7zinWjG | +| create stake account | [`5tVbV1rPpPrMUtQx3r7u27Zsi8oBoUD9RsExnRDiP5vBhg25sRePn2LRDGVA8XkNAYNPV61T4mGqyfsLr3qSPADH`](https://explorer.solana.com/tx/5tVbV1rPpPrMUtQx3r7u27Zsi8oBoUD9RsExnRDiP5vBhg25sRePn2LRDGVA8XkNAYNPV61T4mGqyfsLr3qSPADH?cluster=devnet) | 481486546 | 1100000000 lamports, staker = the pool's vault | +| submit_spend | [`3Uu5xixZijsUh3bWjjG6Kb2QdRrTaABJs6Y3qVTGVDMq7Dj4Mf7AHNpDcu34WXoaJPyZWVmob9s3EpR6tDaTWLEh`](https://explorer.solana.com/tx/3Uu5xixZijsUh3bWjjG6Kb2QdRrTaABJs6Y3qVTGVDMq7Dj4Mf7AHNpDcu34WXoaJPyZWVmob9s3EpR6tDaTWLEh?cluster=devnet) | 481486578 | note 1, relay-signed, delegate to i7NyKBMJCA9bLM2nsGyAGCKHECuR2L5eh4GqFciuwNT | +| create stake account | [`kgGHhhSXbYH5E8x4QJsGN92TVoLL7oY9q3ToDkqRjgrQVPMZSjA4VGgA1SzCcTrz8hrMEUmkDN6oXzUdyDdjM56`](https://explorer.solana.com/tx/kgGHhhSXbYH5E8x4QJsGN92TVoLL7oY9q3ToDkqRjgrQVPMZSjA4VGgA1SzCcTrz8hrMEUmkDN6oXzUdyDdjM56?cluster=devnet) | 481486584 | 1100000000 lamports, staker = the pool's vault | +| submit_spend | [`5BGA7DnFbLcrKEF7ihMgvGahHZonA1vxQkEEdEW3tr5w2dQuTMuULCKn4PYJpMjeJyAUoUT1CsjKRJZSL8kZ8Npp`](https://explorer.solana.com/tx/5BGA7DnFbLcrKEF7ihMgvGahHZonA1vxQkEEdEW3tr5w2dQuTMuULCKn4PYJpMjeJyAUoUT1CsjKRJZSL8kZ8Npp?cluster=devnet) | 481486616 | note 2, relay-signed, delegate to 5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN | +| create stake account | [`yRcgWcHU9gcQDp273sGwijgb7GqKJznPY7ekkERQLQBpsvS5w73uDy9yUj1QJixYBJrVV2htbGe7WPpDuCB22re`](https://explorer.solana.com/tx/yRcgWcHU9gcQDp273sGwijgb7GqKJznPY7ekkERQLQBpsvS5w73uDy9yUj1QJixYBJrVV2htbGe7WPpDuCB22re?cluster=devnet) | 481486622 | 1100000000 lamports, staker = the pool's vault | +| submit_spend | [`4jxdCPTCk2niK96wYPUyveHmAV7KW4evzD1gPuuNnKNNWZgVQwKNpCyUDJ8fn7n9ZVV9fxu4KsoQQeM4eQbCaLZ`](https://explorer.solana.com/tx/4jxdCPTCk2niK96wYPUyveHmAV7KW4evzD1gPuuNnKNNWZgVQwKNpCyUDJ8fn7n9ZVV9fxu4KsoQQeM4eQbCaLZ?cluster=devnet) | 481486654 | note 3, relay-signed, delegate to 23AoPQc3EPkfLWb14cKiWNahh1H9rtb3UBk8gWseohjF | +| create stake account | [`4hwcy4SogdqBU2h17dgdtbsAoSqCNw1Xv5V3RdZbzaWWiVXF4W2f5JUru921ZjVEnF8rAoEtoJtD7azejzh2gVb3`](https://explorer.solana.com/tx/4hwcy4SogdqBU2h17dgdtbsAoSqCNw1Xv5V3RdZbzaWWiVXF4W2f5JUru921ZjVEnF8rAoEtoJtD7azejzh2gVb3?cluster=devnet) | 481486660 | 1100000000 lamports, staker = the pool's vault | +| submit_spend | [`4Z8W9zM9cqtjyXUea7P92z64d6enLF3qoS9TTY7jFXpArTJEL2uspqVd5gBR6Y7aXWAg4WCweHmG7NPQWrDbUiy4`](https://explorer.solana.com/tx/4Z8W9zM9cqtjyXUea7P92z64d6enLF3qoS9TTY7jFXpArTJEL2uspqVd5gBR6Y7aXWAg4WCweHmG7NPQWrDbUiy4?cluster=devnet) | 481486691 | note 4, relay-signed, delegate to 7AETLyAGJWjp6AWzZqZcP362yv5LQ3nLEdwnXNjdNwwF | +| create stake account | [`5moUKFfBAdCYQnkSoePvg4LgH98jPxC9n8fEd1i1zvH7XoE3vatptN2aHBhYj32uay5RSGK8UrF3hQR5CJG98uTR`](https://explorer.solana.com/tx/5moUKFfBAdCYQnkSoePvg4LgH98jPxC9n8fEd1i1zvH7XoE3vatptN2aHBhYj32uay5RSGK8UrF3hQR5CJG98uTR?cluster=devnet) | 481486697 | 1100000000 lamports, staker = the pool's vault | +| submit_spend | [`3TTRoeAMqYWxjUZPay3wfMEiDPa1homVqugr84A5mkXLL7Agb3mv3iYp7wGZUWDpNRVZn5Z67ZvWbSnLU3kLPdR2`](https://explorer.solana.com/tx/3TTRoeAMqYWxjUZPay3wfMEiDPa1homVqugr84A5mkXLL7Agb3mv3iYp7wGZUWDpNRVZn5Z67ZvWbSnLU3kLPdR2?cluster=devnet) | 481486729 | note 5, relay-signed, delegate to 2u83Dx5qPV4QnujjJQv8v2SoqG1ixuAxPK5Jwhtkovd1 | +| settle_epoch | [`5JY4w8ydCDGbpgdQ8x7uxpEbebbuS3JzB6D6DDoEC6PYH9CsFie8ztYh8ZfGQdGWrQU7r8ZPUHLXcdmrR82GHhxg`](https://explorer.solana.com/tx/5JY4w8ydCDGbpgdQ8x7uxpEbebbuS3JzB6D6DDoEC6PYH9CsFie8ztYh8ZfGQdGWrQU7r8ZPUHLXcdmrR82GHhxg?cluster=devnet) | 481486735 | 6 delegations to 6 different validators, one transaction, 1195 bytes | + +Devnet history is pruned, so a signature above may one day return null from `getTransaction` without having failed. The slot is what tells those two apart: `getSignatureStatuses` with `--search-transaction-history` still answers for a pruned transaction. A dash means this run could not read the slot back, and that row is the one to check by hand. ## Reproducing this document -Every value above was written by the run that produced it, and the run recorded them in `data/crowd-result-43000007.json`. `mirror crowd --render-only` rebuilds this file from that record without touching a cluster, so the prose around a number can be improved without re-running a measurement — and a number cannot be changed without re-running one. +Every value above was written by the run that produced it, and the run recorded them in `data/crowd-result-47000011.json`. `mirror crowd --render-only` rebuilds this file from that record without touching a cluster, so the prose around a number can be improved without re-running a measurement — and a number cannot be changed without re-running one. Every address and signature here is on devnet and can be checked against the cluster rather than against this file. @@ -99,13 +101,15 @@ Every address and signature here is on devnet and can be checked against the clu It would be easy to publish this section's numbers and let a reader take them for a privacy result. They are not one, and the honest way to show that is to run the measurement this repository is built around against *this run* rather than only against somebody else's pool. -| quantity | this run | -|---|---| -| nominal k | 6 | -| provenance classes | 1 | -| ρ, the loss factor | 1.0000 | -| effective k (Shannon) | 6.00 | -| effective k (min-entropy) | 6.00 | +| quantity | this run | unresolved bracket | +|---|---|---| +| nominal k | 6 | — | +| provenance classes | 1 | — | +| ρ, the loss factor | 1.0000 | 1.0000 … 1.0000 | +| effective k (Shannon) | 6.00 | 6.00 … 6.00 | +| effective k (min-entropy) | 6.00 | 6.00 … 6.00 | + +The bracket collapses onto the point because all 6 members resolved and none were left over — not because the figure needs no bracket. Every ρ this repository publishes carries one. **ρ = 1.0000 is the best value the metric can return, and it is meaningless here.** Every note in this pool was deposited by the same wallet, so the partition has one class holding all 6 members; an adversary who learns a member's funding class learns nothing, and the metric correctly reports no loss *through that channel*. What it cannot report is that the single class is the operator, who funded every deposit and every relay and therefore knows which member is which. Against that adversary the anonymity set is **one**, and no funding-provenance number will ever say so, because provenance is not the channel that failed. diff --git a/docs/INCENTIVES.md b/docs/INCENTIVES.md index 5a4cdcc8..d4b24a70 100644 --- a/docs/INCENTIVES.md +++ b/docs/INCENTIVES.md @@ -50,8 +50,9 @@ reason to leave. The timeout has a cost and `THREAT_MODEL.md` argues it rather than hiding it: a batch that settles on the clock has no floor, so a batch of one is reachable by -anyone willing to wait an hour. `mirror settle` warns when it is about to publish -one. +anyone willing to wait the pool's timeout out. `mirror settle` refuses to publish +one unless told to with `--allow-below-floor`, and the program marks the +settlement that results. ### Somebody else is paid to sign for you diff --git a/docs/PROOF.md b/docs/PROOF.md index 9633437c..cdeca66d 100644 --- a/docs/PROOF.md +++ b/docs/PROOF.md @@ -4,27 +4,29 @@ Every line here is a transaction that landed. Nothing is simulated: the proof is - cluster: `devnet` - program: `8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa` -- pool: `JBvD5u5foKCThTfSx1TozGHGCcfWu5gDNy1u2FphP51q` -- vault: `EwiXhCnLcg6jEaHMumo5H4tZnVyoCtBPHU5R6hE798R5` +- pool: `4hHZHNL4fdgbqhso1nh5qCzrbt67GXRQ8h5C62fGEkvt` +- vault: `21Az6ixXzVDD7pncK3RRMYoTphQE7MsmvamhhwmAJQPK` ## Flows -| step | signature | note | -|---|---|---| -| init_pool | [`4Ru5RVJ1pyoFSYKrf58Rj43LsawpHQymEwW9zQgKNPpGuv5MdSBzT1Fyxpo8dtdXmcCxCZZN2SwEZVvnkSQXM5dn`](https://explorer.solana.com/tx/4Ru5RVJ1pyoFSYKrf58Rj43LsawpHQymEwW9zQgKNPpGuv5MdSBzT1Fyxpo8dtdXmcCxCZZN2SwEZVvnkSQXM5dn?cluster=devnet) | denomination 20000023, k_floor 4 | -| deposit | [`2z4zzRhA98HJcMHCMF9wzqYMjYCbBNX8K52ASWjhcFAQ887hCzHd1woURxLt2REgsTsLRMn32NubEiXqpGRTCHhZ`](https://explorer.solana.com/tx/2z4zzRhA98HJcMHCMF9wzqYMjYCbBNX8K52ASWjhcFAQ887hCzHd1woURxLt2REgsTsLRMn32NubEiXqpGRTCHhZ?cluster=devnet) | note 1 | -| deposit | [`45uwVLTFs81kU5rv4LgKHWS4apiDoThVRMrq8LziKr5iX9papXySRC3S1fXumZ8UEn4otMzG6iyT6LhcgRSYxu5d`](https://explorer.solana.com/tx/45uwVLTFs81kU5rv4LgKHWS4apiDoThVRMrq8LziKr5iX9papXySRC3S1fXumZ8UEn4otMzG6iyT6LhcgRSYxu5d?cluster=devnet) | note 2 | -| deposit | [`4QaHJBxGD1St4hiLyqmrmJmztTbmam1ssGrSABFE3S6sM6Q4JfSY1oqG5onXty17RT7KNaARXjTM2HYJMsyvur6D`](https://explorer.solana.com/tx/4QaHJBxGD1St4hiLyqmrmJmztTbmam1ssGrSABFE3S6sM6Q4JfSY1oqG5onXty17RT7KNaARXjTM2HYJMsyvur6D?cluster=devnet) | note 3 | -| deposit | [`5ZtoW7nNqVwSYJuzmQqwBiEm52tRgcQdYYGLbGMmD8cN1cqtbqh3mdjRqQzKBqWeetnnoQhTHBaUebTq8SxmBovW`](https://explorer.solana.com/tx/5ZtoW7nNqVwSYJuzmQqwBiEm52tRgcQdYYGLbGMmD8cN1cqtbqh3mdjRqQzKBqWeetnnoQhTHBaUebTq8SxmBovW?cluster=devnet) | note 4 | -| deposit | [`5TdTHdRJXddC1JP6uW4C993LY7ifSoi4ZxbavwhK95Wq96MyGUmHuQm88tkRRykhz4pzpaCQhJC3TR2A7Y843jxB`](https://explorer.solana.com/tx/5TdTHdRJXddC1JP6uW4C993LY7ifSoi4ZxbavwhK95Wq96MyGUmHuQm88tkRRykhz4pzpaCQhJC3TR2A7Y843jxB?cluster=devnet) | note 5 | -| create stake account | [`WmqHSUXx71nm81RzbhjgRZir1hzTMzGxg6k45fgPCi8cbyt1D3FVR5DKAETSapCmeQojM1TzMgAGbotReLBW4Kz`](https://explorer.solana.com/tx/WmqHSUXx71nm81RzbhjgRZir1hzTMzGxg6k45fgPCi8cbyt1D3FVR5DKAETSapCmeQojM1TzMgAGbotReLBW4Kz?cluster=devnet) | 1100000000 lamports, staker = the pool's vault, withdrawer = the operator | -| submit_spend | [`42bLZCNNwSo82r3Zwq1VVB4PqQCFm5ETjiFBai9QZYjNvzrkeJBwiYWb47RRMyXJ3Q9w22yV88GdSUFZ2CRKfGMK`](https://explorer.solana.com/tx/42bLZCNNwSo82r3Zwq1VVB4PqQCFm5ETjiFBai9QZYjNvzrkeJBwiYWb47RRMyXJ3Q9w22yV88GdSUFZ2CRKfGMK?cluster=devnet) | note 0, relay-signed | -| submit_spend | [`4jJwmRta9miD3yQgvhWSzZysZTS5wsKPM4E1ZPnHMnUCa3pPxXew4DQQAG3Y7Luk1bECD9V3A39dSBDrXxY3Wegw`](https://explorer.solana.com/tx/4jJwmRta9miD3yQgvhWSzZysZTS5wsKPM4E1ZPnHMnUCa3pPxXew4DQQAG3Y7Luk1bECD9V3A39dSBDrXxY3Wegw?cluster=devnet) | note 1, relay-signed | -| submit_spend | [`4fpZU88zDAeu2ySvjdit7My1FTzp9P7MZbRHHzUDD3V53nBLMruw5vb1WPG2zmGUnt9AFD1j6uimGmzKsYiLDF8A`](https://explorer.solana.com/tx/4fpZU88zDAeu2ySvjdit7My1FTzp9P7MZbRHHzUDD3V53nBLMruw5vb1WPG2zmGUnt9AFD1j6uimGmzKsYiLDF8A?cluster=devnet) | note 2, relay-signed | -| submit_spend | [`3gEhEPza2ryHiJuxPo8fZktqyhwyh2fwCwx18Bw7q95NPrZaszqpLtZq6KfrXefD7RR5eSbiR4Di5etv6HAWGaYL`](https://explorer.solana.com/tx/3gEhEPza2ryHiJuxPo8fZktqyhwyh2fwCwx18Bw7q95NPrZaszqpLtZq6KfrXefD7RR5eSbiR4Di5etv6HAWGaYL?cluster=devnet) | note 3, relay-signed, action: pool-signed CPI to SPL Memo | -| submit_spend | [`XLZJ3wDgJXe9YhiVD2akpEKHhwMziExkMntUtbW86TkKn6ktw5hWNrmqYG7FZ51jiYuGr6PhnE87v4kvEacViBA`](https://explorer.solana.com/tx/XLZJ3wDgJXe9YhiVD2akpEKHhwMziExkMntUtbW86TkKn6ktw5hWNrmqYG7FZ51jiYuGr6PhnE87v4kvEacViBA?cluster=devnet) | note 4, relay-signed, action: pool-signed stake delegation | -| settle_epoch | [`4T6MYo8rMW6wYZb6FdKSQUSrinLGFoo6gpq5YMQ4RAqCnFbHdeSueeMosEkoBf5uYEhcQRtiCJA16BtvKAKZ58km`](https://explorer.solana.com/tx/4T6MYo8rMW6wYZb6FdKSQUSrinLGFoo6gpq5YMQ4RAqCnFbHdeSueeMosEkoBf5uYEhcQRtiCJA16BtvKAKZ58km?cluster=devnet) | 5 spends in one transaction, 2 of them a CPI the pool signed | -| deposit | [`2jXf1wQ3J3And4TjAS47oUggAzwGFc2wVGy4v4xDyBeoVopNfa6RdW4SCBrNcva3ayjQQcFSZLdRtA8FJockosZ5`](https://explorer.solana.com/tx/2jXf1wQ3J3And4TjAS47oUggAzwGFc2wVGy4v4xDyBeoVopNfa6RdW4SCBrNcva3ayjQQcFSZLdRtA8FJockosZ5?cluster=devnet) | note 6 | +| step | signature | slot | note | +|---|---|---|---| +| init_pool | [`2Jd2TR87LaKaNr8Pa34PnbnnpgFZ7QJtPPhZYSMTimCvo58t4zbHD9Td1bTQtrxBkuenUv5BZcjRELtJYCe4nzLd`](https://explorer.solana.com/tx/2Jd2TR87LaKaNr8Pa34PnbnnpgFZ7QJtPPhZYSMTimCvo58t4zbHD9Td1bTQtrxBkuenUv5BZcjRELtJYCe4nzLd?cluster=devnet) | 481486119 | denomination 20000029, k_floor 4 | +| deposit | [`57vE8o7qvfVuVnozMq1bSeNrJuigYYpkfuFjo1Nspi4tX3Pa9bitPvZU2ebsQLA4uKMZrRXtnUofMMPuDDz87GqC`](https://explorer.solana.com/tx/57vE8o7qvfVuVnozMq1bSeNrJuigYYpkfuFjo1Nspi4tX3Pa9bitPvZU2ebsQLA4uKMZrRXtnUofMMPuDDz87GqC?cluster=devnet) | 481486125 | note 1 | +| deposit | [`4nDwWr1QonzzV94EJyxXy4QbMDB5EmY3y8N8eZa5iRDnaL9ukgseYkWireERqRDSJCVRWjd24qzPnyxWZS7aaKnW`](https://explorer.solana.com/tx/4nDwWr1QonzzV94EJyxXy4QbMDB5EmY3y8N8eZa5iRDnaL9ukgseYkWireERqRDSJCVRWjd24qzPnyxWZS7aaKnW?cluster=devnet) | 481486130 | note 2 | +| deposit | [`3ySCPPmNytc8cPhmeqY8QHuhbW4XWk5bXTTHR3xTPuLeLCYSS6hjQ748zThLK3ysxoDXoDu8RztoXFAe2wxzNvQe`](https://explorer.solana.com/tx/3ySCPPmNytc8cPhmeqY8QHuhbW4XWk5bXTTHR3xTPuLeLCYSS6hjQ748zThLK3ysxoDXoDu8RztoXFAe2wxzNvQe?cluster=devnet) | 481486136 | note 3 | +| deposit | [`5SKjtdFgkxY8qNUD1MFSDccMVaPdJTnebFFwD7wVHTbGSY3hdXGGKNbgRhBT9v2yWGoN3JGmHKHwe1mYR3ERNSYL`](https://explorer.solana.com/tx/5SKjtdFgkxY8qNUD1MFSDccMVaPdJTnebFFwD7wVHTbGSY3hdXGGKNbgRhBT9v2yWGoN3JGmHKHwe1mYR3ERNSYL?cluster=devnet) | 481486141 | note 4 | +| deposit | [`3ihbqsBgKYiCmtGVyN4fNgDPifyfwFKJ2tSoteSRvRqj1rq7edFLixqxA9i7qVpHEHSTXQ4MV69aEvfHqz7SPeDc`](https://explorer.solana.com/tx/3ihbqsBgKYiCmtGVyN4fNgDPifyfwFKJ2tSoteSRvRqj1rq7edFLixqxA9i7qVpHEHSTXQ4MV69aEvfHqz7SPeDc?cluster=devnet) | 481486147 | note 5 | +| create stake account | [`5Jcgr2aMUCyzk8YJwdZCAwtHuRfHXUMTsbgPKWHHsCkZhtqvx9pEZpFUjjhRSZE1hsf43Po38kiHweKRSC5rMdcP`](https://explorer.solana.com/tx/5Jcgr2aMUCyzk8YJwdZCAwtHuRfHXUMTsbgPKWHHsCkZhtqvx9pEZpFUjjhRSZE1hsf43Po38kiHweKRSC5rMdcP?cluster=devnet) | 481486153 | 1100000000 lamports, staker = the pool's vault, withdrawer = the operator | +| submit_spend | [`WtXw9KtimxeXkyB6ztxN43k4DfMDHJ3yhqdPs5zTzoMK7FGojnkWHKTDVD6bm26dTw9vbwTC8JZ3JjSYSrygTns`](https://explorer.solana.com/tx/WtXw9KtimxeXkyB6ztxN43k4DfMDHJ3yhqdPs5zTzoMK7FGojnkWHKTDVD6bm26dTw9vbwTC8JZ3JjSYSrygTns?cluster=devnet) | 481486180 | note 0, relay-signed | +| submit_spend | [`6BEoAQFrGMCqcJeaaekiS2S8na9MW5X6tpc9YERVkuZvVFBSQbKjA1Bk4YGGcxS2kkRRApKPiY4yjR1U76WBoA1`](https://explorer.solana.com/tx/6BEoAQFrGMCqcJeaaekiS2S8na9MW5X6tpc9YERVkuZvVFBSQbKjA1Bk4YGGcxS2kkRRApKPiY4yjR1U76WBoA1?cluster=devnet) | 481486207 | note 1, relay-signed | +| submit_spend | [`5joi2J71sCz9bPVLZBeVoUEDchJE8oD6FF2aH4o2bokjWUx9oW1aWdicUWifbqAstyTtwEJUfR5a6gG1mxQGUk46`](https://explorer.solana.com/tx/5joi2J71sCz9bPVLZBeVoUEDchJE8oD6FF2aH4o2bokjWUx9oW1aWdicUWifbqAstyTtwEJUfR5a6gG1mxQGUk46?cluster=devnet) | 481486235 | note 2, relay-signed | +| submit_spend | [`WqcPensA2X37HGSC98jai5ceEKbxAT4AKMANBcDVwcaYWzxhnjkf9SyxDTee35qMERnYzcE8Kw6eLD99Q9cHUbT`](https://explorer.solana.com/tx/WqcPensA2X37HGSC98jai5ceEKbxAT4AKMANBcDVwcaYWzxhnjkf9SyxDTee35qMERnYzcE8Kw6eLD99Q9cHUbT?cluster=devnet) | 481486261 | note 3, relay-signed, action: pool-signed CPI to SPL Memo | +| submit_spend | [`3mwBWN29M3uRGAmtiAEMUCERhJdjoRfhvo8RFWSwGmFL3XvzoZuJabLdCbdoYVhZC88JCNwkhUbEXEriFLkQqMRt`](https://explorer.solana.com/tx/3mwBWN29M3uRGAmtiAEMUCERhJdjoRfhvo8RFWSwGmFL3XvzoZuJabLdCbdoYVhZC88JCNwkhUbEXEriFLkQqMRt?cluster=devnet) | 481486289 | note 4, relay-signed, action: pool-signed stake delegation | +| settle_epoch | [`5cw6HK2Xb6KrkR2qv5ECofSZN59hDmxdeKkgTiV4yVVWgucqtwAujMXKeCmxWBgNG4JHKQUG94GGfWqoHu9Anvqc`](https://explorer.solana.com/tx/5cw6HK2Xb6KrkR2qv5ECofSZN59hDmxdeKkgTiV4yVVWgucqtwAujMXKeCmxWBgNG4JHKQUG94GGfWqoHu9Anvqc?cluster=devnet) | 481486295 | 5 spends in one transaction, 2 of them a CPI the pool signed | +| deposit | [`3UH1uZao97QjT6vz69XShcC7GkMG5AddqRz1wtp3vzNYTujHuPkNDft7STECkJZgqnmZTUCA9g3mJFyw4VrAQT5q`](https://explorer.solana.com/tx/3UH1uZao97QjT6vz69XShcC7GkMG5AddqRz1wtp3vzNYTujHuPkNDft7STECkJZgqnmZTUCA9g3mJFyw4VrAQT5q?cluster=devnet) | 481486302 | note 6 | + +The slots are there because **devnet history is pruned**. Every signature above resolved through `getTransaction` when this file was written, and a reader coming to it later may find that call returning null for a transaction that did land. That is the cluster forgetting, not the evidence being wrong, and the way to tell the difference is `getSignatureStatuses` with `--search-transaction-history`, which still answers for a pruned transaction — checked against the slot in this table. ## Vault accounting @@ -32,14 +34,14 @@ The accounting invariant is a statement about the vault's lamports, so here are | quantity | lamports | |---|---| -| denomination | 20000023 | +| denomination | 20000029 | | notes settled | 5 | | relay fee (taken out of the denomination, not added) | 200000 | -| vault before settlement | 100890995 | +| vault before settlement | 100891025 | | vault after settlement | 890880 | | rent-exempt minimum, 0 bytes | 890880 | -| **paid out** | **100000115** | -| **owed** (denomination × notes) | **100000115** | +| **paid out** | **100000145** | +| **owed** (denomination × notes) | **100000145** | Paid out equals owed, and the vault came to rest on its floor with a remainder of 0 lamports. The soak asserts both and fails the run otherwise, so this table cannot record a discrepancy and still exit successfully. @@ -50,14 +52,14 @@ The settlement above carried 5 spends, and 2 of them were not a transfer: the po A signature only proves the transaction landed. It says nothing about who signed the instruction the pool made *inside* it, so the evidence has to come from the callee. SPL Memo refuses any account handed to it that has not signed, and names the ones that did: ``` -Program log: Signed by EwiXhCnLcg6jEaHMumo5H4tZnVyoCtBPHU5R6hE798R5 +Program log: Signed by 21Az6ixXzVDD7pncK3RRMYoTphQE7MsmvamhhwmAJQPK ``` -That is the pool's vault, `EwiXhCnLcg6jEaHMumo5H4tZnVyoCtBPHU5R6hE798R5`, which has no private key — it signed through seeds only the program holds. The soak reads this line back from the cluster and fails the run if it is absent, so this section cannot appear without the callee having said it. +That is the pool's vault, `21Az6ixXzVDD7pncK3RRMYoTphQE7MsmvamhhwmAJQPK`, which has no private key — it signed through seeds only the program holds. The soak reads this line back from the cluster and fails the run if it is absent, so this section cannot appear without the callee having said it. ## The pool delegated stake, as a member's authority -The same settlement carried a second signed action, and this one is the case the design exists for: a **real stake delegation**. Stake account [`CMMwt1SgrJyEfdBsxxUNSwsPwNiU94ZNkVGMUzvMU1Ri`](https://explorer.solana.com/address/CMMwt1SgrJyEfdBsxxUNSwsPwNiU94ZNkVGMUzvMU1Ri?cluster=devnet) is now delegated to validator [`2f9C9AU8nFRKUub8NHToNiZzcwmYiNeipVuP8akKgRVv`](https://explorer.solana.com/address/2f9C9AU8nFRKUub8NHToNiZzcwmYiNeipVuP8akKgRVv?cluster=devnet). +The same settlement carried a second signed action, and this one is the case the design exists for: a **real stake delegation**. Stake account [`BwxCHw288XQNPiapnUjTJfbYVKMZSsmi49X84jY3rFdG`](https://explorer.solana.com/address/BwxCHw288XQNPiapnUjTJfbYVKMZSsmi49X84jY3rFdG?cluster=devnet) is now delegated to validator [`2f9C9AU8nFRKUub8NHToNiZzcwmYiNeipVuP8akKgRVv`](https://explorer.solana.com/address/2f9C9AU8nFRKUub8NHToNiZzcwmYiNeipVuP8akKgRVv?cluster=devnet). `DelegateStake` requires the **staker authority** to sign. No member can be that authority without appearing on chain and undoing the point, so the pool is, and the pool signed. The account state is read back after settlement: a stake account only reaches the `Stake` variant by being delegated — an initialised but undelegated one is a different variant — so the check distinguishes "the instruction landed" from "the delegation took", and the validator's key is read out of the account rather than assumed from what was requested. @@ -78,6 +80,20 @@ A negative case is only evidence if the program's own error code is what rejecte The last three attack a note that is still live, deposited after settlement precisely so that they would have to. Against an already-spent note the replay guard fires first and the rejection would say nothing about the check under test — which is how a negative case comes to pass for the wrong reason. +## The key those proofs were checked against + +Every proof above was verified on chain against `programs/mirror-pool/src/vk.rs`, and that file is generated. A wrong byte in it is not a compile error, not a failure anywhere else in the suite, and not visible in a diff anyone reads carefully — so the binding between the committed circuit and the deployed key is asserted by a test rather than left to inspection. + +`programs/mirror-pool/tests/vk_drift.rs` regenerates the key from the committed seed under the committed `Cargo.lock` and compares it to the program's own constants **element by element**, then separately checks that the digest `README.md` publishes is the digest of that key. Two tests rather than one, because if both fail the key moved and if only the second fails the documentation is stale — and knowing which without reading any code is the point. + +The same check by hand, against the deployed program: + +``` +mirror verify-setup --expect +``` + +What this establishes is *reproducibility*, not security. The seed is public, so the toxic waste is public, so proofs against this key are forgeable — which is why the program is on devnet and stays there. A real multi-party ceremony is the prerequisite for anything value-bearing, and `docs/THREAT_MODEL.md` says so rather than leaving it to be discovered. + ## Scope Devnet is devnet. This is a live-cluster functional proof, not a claim of mainnet operation or of an anonymity crowd: the run is scripted by one operator with a handful of notes. What it establishes is that the circuit, the prover, the byte layout and the deployed program agree on a real validator. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 5a915596..7213b07a 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -310,8 +310,9 @@ is no confidential-value layer here. ### The crowd rule is threshold-or-timeout, and the timeout side has no floor A batch settles if it carries `k_floor` spends **or** if every spend in it has -waited out `SETTLE_TIMEOUT_SECONDS` (an hour). The second clause has no minimum -size. **A batch of one settles, and executes.** +waited out the pool's settlement timeout — a value fixed at creation, defaulting +to an hour. The second clause has no minimum size. **A batch of one settles, and +executes.** This is the standard trade in mix design, and the standard analysis of it is Serjantov, Dingledine and Syverson, *From a Trickle to a Flood: Active Attacks on @@ -322,7 +323,7 @@ the paper, though — it follows from the code: - **Settlement is permissionless**, so an adversary may be the settler. They choose the moment and the composition of every batch they send. -- A spend submitted at `t` becomes settleable **alone** at `t + 3600`, +- A spend submitted at `t` becomes settleable **alone** at `t + timeout`, regardless of what else is pending. - So for any member whose spend outlives the timeout without company, an adversary can settle it by itself, and that member's anonymity set is one. @@ -347,6 +348,36 @@ pending count before it settles and says plainly when a batch is below the floor because a member who is about to settle alone should know that is what they are doing. What the tool cannot do is manufacture other members. +**What the program does about it**, short of refusing the trade. Two things, and +neither of them changes what is permitted: + +- **The settlement has to be asked for.** `SettleEpoch` carries an + `allow_below_floor` flag, and without it an under-floor batch is refused with + its own error code (`BelowFloorNotPermitted`, 26) rather than settled. The flag + is consent and not a bypass: the timeout still has to have run, and a batch + that has not waited it out is still refused with `CrowdTooSmall` (21). The two + codes are distinct so a rejection says which gate closed. `mirror settle` + requires `--allow-below-floor` for the same reason, and stops with an + explanation rather than warning and proceeding. +- **The settlement leaves a mark.** A batch that lands below the floor logs the + count it carried, the floor it missed, and the timeout it settled on. Before + this, a solo timeout settlement and a full crowd were the same transaction + shape read two ways — an observer could count accounts, but the floor it fell + short of was not in the transaction at all. Now the members of that batch can + tell afterwards what crowd they actually got. + +Neither closes the hole. An adversarial settler will happily pass the flag, and +the mark tells you about your anonymity after the fact rather than protecting it. +What they buy is that the trade is now visible in the ledger and deliberate in +the tooling, instead of a default that happens quietly to whoever waited longest. + +**The timeout is per pool.** It is set at creation alongside `k_floor`, immutable +afterwards for the same reason, and bounded to between a minute and seven days — +a timeout that never elapses would escrow deposits with no crowd and no clock +able to release them, which is the griefing vector `MAX_K_FLOOR` already guards +against from the other direction. Zero means the program's default of an hour, +which is what every pool created before the parameter existed holds. + **What would fix it properly**, and is not built: a batch that fails to reach the floor could *refund* the member rather than execute — the escape hatch would then cost the member their action instead of their anonymity. That is a different diff --git a/docs/USAGE.md b/docs/USAGE.md index ee58ac3e..8e271c52 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -34,23 +34,30 @@ set is worse for everyone in it. ``` $ mirror init-pool --program 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa \ - --denomination 31000001 --k-floor 2 -pool DQ17r5reCu5P4efUzHQZq7Ye6D6vt72ThxatNdLBqcKt -vault 81odk482H6VpRQMk8tQaoThYr2ffJtugcJXjmKX2faco -signature 3Tbf4zMGDHW89bi3F7yhaGMAB6RqveDi5WTJAYKEe3Zr2F8eTAeT8zSTp6V3B4kWE4ZSXmpMdxpx6UayvdsiNNiw + --denomination 31000003 --k-floor 2 +pool 4754WzZxtuVat5RZLKkh6JNpGx5pGjz2F1gLjQGLkmnU +vault 2VgGhVgfLuYqxdKMFcRtKwsrXiSjtpBn2y5bGFdFixkq +timeout 3600s (the program's default: this pool set none) +signature 3UxVSDBCtJPyjdjispAKfJjbUD5272P4Yc6D3SdTrNDomLregNnpkGMJxWowyEH2o4K3MYj7MeEdh65o7UiAgCsV ``` `--k-floor` is the number of notes the pool must hold before it will act at all. It bounds *program-visible* membership, which is all a program can check — see `THREAT_MODEL.md` for what it does not bound. +`--settle-timeout` is how long a spend waits before it may settle below that +floor, in seconds, between 60 and 604800. It defaults to 0, meaning the +program's own hour. Like the floor, it is fixed at creation and cannot be +changed afterwards, so a pool that wants something other than an hour has to say +so here. + ## 2. A note ``` -$ mirror note-new --denomination 31000001 --out m1.json +$ mirror note-new --denomination 31000003 --out m1.json wrote m1.json - denomination 31000001 - commitment 1ca8f208a35a6327c7871ee6d38268527260f53299c21b25133b32178989701c + denomination 31000003 + commitment 01400612e536431914d827909319e451e6539ab823d0496cd48f18655aab248e This file is the note. Treat it exactly as you would a keypair: anyone holding it can spend the deposit, and losing it loses the @@ -70,9 +77,12 @@ overwrite here destroys a deposit. ``` $ mirror deposit --program 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa --note m1.json -deposited 31000001 lamports into DQ17r5reCu5P4efUzHQZq7Ye6D6vt72ThxatNdLBqcKt +deposited 31000003 lamports into 4754WzZxtuVat5RZLKkh6JNpGx5pGjz2F1gLjQGLkmnU leaf 0 -signature JpCxFU4ipaBap15AWRxNVFoy8rskWZnUTFoBTLx1JBs5V2TKDCx7HhhzQ4AtYxL46yvfS8U1yXzSVLNg2au89hS +signature 4Mg3MhgrNdf583LtxGupULbuC66V1SizFx9qxc6npU4WU2AhnuB5FHQP5XaiBt5Utr3CVYvPoMrs9W3PpcUX7EAz + +Keep m1.json safe. It is the only way to spend this note, +and nobody — including this pool's authors — can reissue it. ``` The deposit is public and it is signed by you. That is fine and unavoidable — @@ -84,20 +94,28 @@ deposit cannot claim a size the pool did not set. ## 4. Check the tree — optional, and worth doing once ``` -$ mirror tree --program 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa --denomination 31000001 -pool DQ17r5reCu5P4efUzHQZq7Ye6D6vt72ThxatNdLBqcKt -vault 81odk482H6VpRQMk8tQaoThYr2ffJtugcJXjmKX2faco -denomination 31000001 +$ mirror tree --program 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa --denomination 31000003 +pool 4754WzZxtuVat5RZLKkh6JNpGx5pGjz2F1gLjQGLkmnU +vault 2VgGhVgfLuYqxdKMFcRtKwsrXiSjtpBn2y5bGFdFixkq +denomination 31000003 k floor 2 +timeout 3600s notes 2 deposited, 0 settled, 2 outstanding + That k is program-visible membership only, and the effective set is smaller. + Measured against a live pool of comparable shape — Privacy Cash, + 9fhQBbumKEFuXtMBDw8AaQyAjCorLGJQiS3skWZdQyQD — knowing a member's funding + class left ρ = 0.0955, inside an unresolved bracket of 0.0350 .. 0.1136. Roughly + an order of magnitude of the nominal figure. + Method, and what it does not cover: docs/PROVENANCE_METHOD.md. + rebuilding the accumulator from chain history: 3 transactions touched this pool leaves recovered 2 pool reports 2 - rebuilt root 0c77cb909067c1a57811be8c05237aff2715c65c6250fdde764ed768096cd732 - on-chain root 0c77cb909067c1a57811be8c05237aff2715c65c6250fdde764ed768096cd732 + rebuilt root 210e6d7fb10346b463f62d5e351442b6e09e9958c0699310c07ca0835580cea1 + on-chain root 210e6d7fb10346b463f62d5e351442b6e09e9958c0699310c07ca0835580cea1 the rebuilt tree matches the chain — proofs built from it will verify ``` @@ -139,9 +157,9 @@ deriving the proving key from the published seed (this takes a moment) proving membership submitted. The note is spent and the action is recorded. - nullifier 02218a1c2f8fe73a88e856ee9cf3c7e01508107490dca23a56af54166566d54e - record 59jcYijM67FYybt6MH36CU9YUMqQQ7Q5Lg6JiTXRXxfz - signature d5Y6CxbrNxu74u88AGT5NU1KqfLb4JgjtBXzT2TyXyvAGqJq2qgf5NftQwKoAH5TfHdh3fTQcbjkwudSDX8tj7U + nullifier 04db90736f4bf9005b79e9000c510804713ad9fe7f2688c22beed948d18d5883 + record 9gPytZBVVcWDT5MecfkgQGNgo8TLrEeyLd9q44eTW4Vn + signature 3xmHiy1fLvCDJGRejgLvvKduhdNbajXLYBRjJvpaAfQHB7zz3SpccbK3wH4v63Pyv8136vHZd1A3a4BTx8JMRcQ2 Nothing has been paid out yet — settlement executes the batch, which is what gives every member's action one timestamp. Run `mirror settle`, or @@ -194,13 +212,13 @@ Below the crowd floor it tells you what it is waiting for rather than failing with an error code: ``` -$ mirror settle --program 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa --denomination 31000001 +$ mirror settle --program 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa --denomination 31000003 looking for spends waiting to settle: 4 transactions touched this pool - 1 spend(s) pending, and this pool's floor is 2. + this batch carries 1 spend(s) and the pool's floor is 2. A batch below the floor may settle once every spend in it has waited 3600s; - the youngest has waited 16s, so 3584s remain. + the youngest has waited 14s, so 3586s remain. This is a liveness guarantee rather than a restriction: nobody can hold a member's funds waiting for a crowd that never arrives. @@ -209,20 +227,51 @@ looking for spends waiting to settle: Once the crowd is there: ``` -$ mirror settle --program 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa --denomination 31000001 +$ mirror settle --program 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa --denomination 31000003 looking for spends waiting to settle: 5 transactions touched this pool +settling 2 spends in one legacy transaction, 437 bytes + settled 2 spends in one transaction - signature 4EA7SszoGBWC1EKzgGn8XdLWvhMiWxp6v6Gbcmd7czzn7LfiUmoBvKwAJsM3XPt4Y2abm5P85M7w939ftRTvi6XC + signature jes9YgrHzYvccsogn1ZGrtDWXtgHuTd2SgWPUucGVTGXMVbAuAnBeV4TGg1BYA1uCuLs1cp9YbgyonZwYqxRsA2 Every payout in that batch shares one timestamp and one ordering, which is what stops arrival time from telling the members apart. + +The 2 members above are that batch's nominal set. + That k is program-visible membership only, and the effective set is smaller. + Measured against a live pool of comparable shape — Privacy Cash, + 9fhQBbumKEFuXtMBDw8AaQyAjCorLGJQiS3skWZdQyQD — knowing a member's funding + class left ρ = 0.0955, inside an unresolved bracket of 0.0350 .. 0.1136. Roughly + an order of magnitude of the nominal figure. + Method, and what it does not cover: docs/PROVENANCE_METHOD.md. ``` -Both beneficiaries received `31000001 − 100000 = 30900001` lamports, in one +Both beneficiaries received `31000003 − 100000 = 30900003` lamports, in one transaction, at one timestamp. +That last paragraph is printed after **every** settlement, deliberately. The +batch size is a nominal anonymity set and it is the number a member walks away +with; the measured discount travels with it rather than living in a document +nobody has open. + +### Settling below the floor + +Once the timeout has run, a batch smaller than the floor *may* settle — and the +command will not do it on your say-so alone. It prints what the members of that +batch would actually get and stops, and settling it takes `--allow-below-floor`. + +That is not bureaucracy. The timeout is a solvency guarantee and it is not an +anonymity one: a batch of one gives its member an anonymity set of one, and the +action becomes attributable to whoever submitted it. The program refuses an +under-floor batch nobody asked for (error `26`), and the settlement that does +land carries a log line naming the count it carried and the floor it missed — so +the members can tell afterwards what crowd they were actually in. + +If your funds would otherwise stay escrowed, that is exactly what the flag is +for. If you can wait for company, wait. + `settle` executes plain transfers on its own. A pending CPI action is left alone and reported, because the record binds *how many* accounts the call takes and never *which*, so no tool can infer the account list its callee expects. @@ -331,7 +380,7 @@ accounts, settles a v0 transaction that refers to them by one byte each, and takes the table back down: ``` -$ mirror settle --program 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa --denomination 5000003 +$ mirror settle --program 8H3cYoiAA9LM36cyPr4UEv38dhHasSu2XPSdiBfyrLEa --denomination 5000011 looking for spends waiting to settle: 49 transactions touched this pool @@ -339,20 +388,38 @@ looking for spends waiting to settle: 64-account lock limit. Run this again for the rest — the cost is a second timestamp, which is a real cost to the anonymity of both halves. - 20 spends do not fit a legacy transaction: 2218 bytes, 986 over the 1232-byte packet. + 20 spends do not fit a legacy transaction: 2219 bytes, 987 over the 1232-byte packet. Settling through a lookup table instead. - lookup table ia9oUXMgArZhGPWyETroygf6bvULHQWBodgpwB43gQ8 + lookup table A5FCt5jXPDpUsbfTwdkLvC9sMUPX1XYdFENPoAngsc3c 62 addresses published - settlement is 332 bytes of 1232, one signature + settlement is 333 bytes of 1232, one signature table deactivated. Close it after ~513 slots to reclaim the rent and remove the published address list: - mirror close-table --table ia9oUXMgArZhGPWyETroygf6bvULHQWBodgpwB43gQ8 + mirror close-table --table A5FCt5jXPDpUsbfTwdkLvC9sMUPX1XYdFENPoAngsc3c settled 20 spends in one transaction - signature enxa9fztmzEHMLsvhfzJwFVRsNWha7WiSEUEeNn7UPk8KAdpnWQDzEHarGL8d4ckyBvtWrEGCkuvgW7UFgFAHzp + signature 4rKtNcZyAqxf5EAXNCtXpde9Zt3qqEEou4cG8GFV1vVA1kGGxFRBaBvZWNfsxVY9d9FgwTyYXYzz8dahaXenGM5C + +Every payout in that batch shares one timestamp and one ordering, which +is what stops arrival time from telling the members apart. + +The 20 members above are that batch's nominal set. + That k is program-visible membership only, and the effective set is smaller. + Measured against a live pool of comparable shape — Privacy Cash, + 9fhQBbumKEFuXtMBDw8AaQyAjCorLGJQiS3skWZdQyQD — knowing a member's funding + class left ρ = 0.0955, inside an unresolved bracket of 0.0350 .. 0.1136. Roughly + an order of magnitude of the nominal figure. + Method, and what it does not cover: docs/PROVENANCE_METHOD.md. ``` -**The packet stops mattering entirely** — twenty members weigh 332 of 1232 +**Twenty is the ceiling because each member locks three distinct accounts** — its +record, its beneficiary and its relay — and the transaction may lock 64 including +the pool's own three. A member whose beneficiary *is* their relay costs two +instead of three, and a batch of those settles 22 in 1713 bytes. That is a real +measurement of a shape nobody uses: paying the relay is paying the one party who +already knows which spend was yours. + +**The packet stops mattering entirely** — twenty members weigh 333 of 1232 bytes. What binds instead is the number of accounts one transaction may lock: three per member plus three for the pool, so twenty members sit at exactly 64. `settle` counts them and defers the rest rather than building a transaction the @@ -364,16 +431,16 @@ settlement touched, published before the settlement landed. Leaving one behind per batch builds a permanent on-chain index of who settled together. ``` -$ mirror close-table --table ia9oUXMgArZhGPWyETroygf6bvULHQWBodgpwB43gQ8 -closed ia9oUXMgArZhGPWyETroygf6bvULHQWBodgpwB43gQ8 - signature 1x3Uv5k8j8MnfWsD6rTksUkV9Mcgwvg6PjfTDJXFHgQ9J6CQ7wih51ANeBtaNXCDktstjBvupR2b5aHZPnoYuFp +$ mirror close-table --table A5FCt5jXPDpUsbfTwdkLvC9sMUPX1XYdFENPoAngsc3c +closed A5FCt5jXPDpUsbfTwdkLvC9sMUPX1XYdFENPoAngsc3c + signature 5vZBtQmkphZv8RFu2b2LRWLQ864iFt5qFarhiD7UkCvJPGeYz8BpbAESU6jUyEhVHWTHE6A9WpasKJkcKWD4Ni5k reclaimed 15084280 lamports, and the published address list is gone ``` **Do not set a crowd floor above what one settlement can carry.** `init-pool` refuses it now, because a pool whose floor exceeds the per-transaction ceiling -can never meet it by crowd and can only settle through the hour-long timeout — -and the floor is fixed at creation, so the fix is a different pool. +can never meet it by crowd and can only settle through the timeout — and the +floor is fixed at creation, so the fix is a different pool. For a legacy batch the limit is the packet in every row. Each spend brings accounts nobody else shares — its record, its beneficiary, its relay — so a diff --git a/programs/mirror-pool/src/error.rs b/programs/mirror-pool/src/error.rs index dc90ce12..8b99e171 100644 --- a/programs/mirror-pool/src/error.rs +++ b/programs/mirror-pool/src/error.rs @@ -79,6 +79,16 @@ pub enum MirrorProgramError { /// — which costs the members the exact property the batch exists to give /// them. Refused rather than documented. FeeNotUniform = 25, + /// The batch is below the crowd size and the settler did not ask for that. + /// + /// Distinct from [`MirrorProgramError::CrowdTooSmall`], and the distinction + /// is the whole point of the flag. `CrowdTooSmall` means the batch *cannot* + /// settle yet — the timeout has not run. This means it could, and nobody + /// said so: a settler who composes an under-floor batch by accident is + /// refused here rather than handed a settlement that costs somebody their + /// anonymity set. Two codes so that a rejection says which of the two + /// happened without anyone having to guess. + BelowFloorNotPermitted = 26, } impl From for ProgramError { diff --git a/programs/mirror-pool/src/instruction.rs b/programs/mirror-pool/src/instruction.rs index 36f501e6..706a2489 100644 --- a/programs/mirror-pool/src/instruction.rs +++ b/programs/mirror-pool/src/instruction.rs @@ -48,6 +48,10 @@ pub enum Instruction { denomination: u64, entry_fee: u64, k_floor: u32, + /// How long a spend waits before it may settle below the floor. Zero + /// means the program's default, which is what every pool created before + /// this field existed holds. + settle_timeout_seconds: u32, }, /// Escrows exactly `denomination + entry_fee` and appends `commitment` to /// the accumulator. @@ -87,17 +91,27 @@ pub enum Instruction { /// `count` is the number of spend records that follow in the account list. /// Permissionless: anyone may settle, so no operator's absence can strand a /// member's funds. - SettleEpoch { count: u8 }, + SettleEpoch { + count: u8, + /// Consent to settling a batch smaller than the pool's floor. + /// + /// The flag does not skip the timeout — an under-floor batch still has + /// to wait it out. What it does is stop that settlement from happening + /// by accident: a batch below the floor costs its members the anonymity + /// set they deposited for, and the program should hear somebody say so + /// rather than infer it from a count. + allow_below_floor: bool, + }, } -/// `InitPool`: tag + u64 + u64 + u32. -pub const INIT_POOL_LEN: usize = 1 + 8 + 8 + 4; +/// `InitPool`: tag + u64 + u64 + u32 + u32. +pub const INIT_POOL_LEN: usize = 1 + 8 + 8 + 4 + 4; /// `Deposit`: tag + one field element. pub const DEPOSIT_LEN: usize = 1 + 32; /// `SubmitSpend` without its payload. The encoding is variable length. pub const SUBMIT_SPEND_BASE_LEN: usize = 1 + 64 + 128 + 64 + 32 + 32 + 8 + 32 + 32 + 8 + 1 + 2; -/// `SettleEpoch`: tag + count. -pub const SETTLE_EPOCH_LEN: usize = 1 + 1; +/// `SettleEpoch`: tag + count + the below-floor flag. +pub const SETTLE_EPOCH_LEN: usize = 1 + 1 + 1; fn read_u64(data: &[u8], at: usize) -> u64 { let mut b = [0u8; 8]; @@ -128,6 +142,7 @@ impl Instruction { denomination: read_u64(data, 1), entry_fee: read_u64(data, 9), k_floor: read_u32(data, 17), + settle_timeout_seconds: read_u32(data, 21), }) } Tag::Deposit => { @@ -142,7 +157,20 @@ impl Instruction { if data.len() != SETTLE_EPOCH_LEN { return Err(MirrorProgramError::MalformedInstruction); } - Ok(Instruction::SettleEpoch { count: data[1] }) + // Exactly zero or one. A bool is one bit of meaning and the + // wire gives it eight, so seven of them have no defined value — + // and "anything nonzero is true" would let a caller send 0x02 + // believing they had asked for something else. Refused for the + // same reason a trailing byte is. + let allow_below_floor = match data[2] { + 0 => false, + 1 => true, + _ => return Err(MirrorProgramError::MalformedInstruction), + }; + Ok(Instruction::SettleEpoch { + count: data[1], + allow_below_floor, + }) } Tag::SubmitSpend => { if data.len() < SUBMIT_SPEND_BASE_LEN { @@ -197,12 +225,14 @@ impl Instruction { denomination, entry_fee, k_floor, + settle_timeout_seconds, } => { let mut out = Vec::with_capacity(INIT_POOL_LEN); out.push(Tag::InitPool as u8); out.extend_from_slice(&denomination.to_le_bytes()); out.extend_from_slice(&entry_fee.to_le_bytes()); out.extend_from_slice(&k_floor.to_le_bytes()); + out.extend_from_slice(&settle_timeout_seconds.to_le_bytes()); out } Instruction::Deposit { commitment } => { @@ -240,8 +270,11 @@ impl Instruction { out.extend_from_slice(payload); out } - Instruction::SettleEpoch { count } => { - vec![Tag::SettleEpoch as u8, *count] + Instruction::SettleEpoch { + count, + allow_below_floor, + } => { + vec![Tag::SettleEpoch as u8, *count, *allow_below_floor as u8] } } } @@ -256,6 +289,7 @@ mod tests { denomination: 100_000_000, entry_fee: 5_000, k_floor: 8, + settle_timeout_seconds: 900, } } @@ -266,7 +300,10 @@ mod tests { } fn settle() -> Instruction { - Instruction::SettleEpoch { count: 7 } + Instruction::SettleEpoch { + count: 7, + allow_below_floor: true, + } } fn submit_spend() -> Instruction { @@ -299,10 +336,11 @@ mod tests { assert_eq!(init().pack().len(), INIT_POOL_LEN); assert_eq!(deposit().pack().len(), DEPOSIT_LEN); assert_eq!(submit_spend().pack().len(), SUBMIT_SPEND_BASE_LEN + 5); - assert_eq!(INIT_POOL_LEN, 21); + assert_eq!(INIT_POOL_LEN, 25); assert_eq!(DEPOSIT_LEN, 33); assert_eq!(SUBMIT_SPEND_BASE_LEN, 404); assert_eq!(settle().pack().len(), SETTLE_EPOCH_LEN); + assert_eq!(SETTLE_EPOCH_LEN, 3); } #[test] @@ -354,10 +392,40 @@ mod tests { denomination: 0x1122_3344_5566_7788, entry_fee: 0x99aa_bbcc_ddee_ff00, k_floor: 0xdead_beef, + settle_timeout_seconds: 0xcafe_f00d, }; assert_eq!(Instruction::unpack(&ix.pack()).unwrap(), ix); } + /// The flag is one bit of meaning in eight bits of wire, and the seven + /// spare ones have no defined value. + /// + /// "Anything nonzero is true" is the tolerant reading, and it is how a + /// caller who sent `2` meaning something of their own comes to settle a + /// batch below the floor believing they asked for no such thing. + #[test] + fn a_below_floor_flag_that_is_neither_zero_nor_one_is_refused() { + for byte in [2u8, 3, 0x80, 0xff] { + assert!( + matches!( + Instruction::unpack(&[Tag::SettleEpoch as u8, 4, byte]), + Err(MirrorProgramError::MalformedInstruction) + ), + "flag byte {byte:#x} was accepted" + ); + } + // And the two that do mean something still decode. + for (byte, expected) in [(0u8, false), (1u8, true)] { + assert_eq!( + Instruction::unpack(&[Tag::SettleEpoch as u8, 4, byte]).unwrap(), + Instruction::SettleEpoch { + count: 4, + allow_below_floor: expected, + } + ); + } + } + #[test] fn a_deposit_commitment_survives_verbatim() { let mut commitment = [0u8; 32]; diff --git a/programs/mirror-pool/src/processor.rs b/programs/mirror-pool/src/processor.rs index 4135455f..668fe75e 100644 --- a/programs/mirror-pool/src/processor.rs +++ b/programs/mirror-pool/src/processor.rs @@ -24,7 +24,15 @@ pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> Pr denomination, entry_fee, k_floor, - } => init_pool(program_id, accounts, denomination, entry_fee, k_floor), + settle_timeout_seconds, + } => init_pool( + program_id, + accounts, + denomination, + entry_fee, + k_floor, + settle_timeout_seconds, + ), Instruction::Deposit { commitment } => deposit(program_id, accounts, commitment), Instruction::SubmitSpend { proof_a, @@ -55,7 +63,10 @@ pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> Pr payload, }, ), - Instruction::SettleEpoch { count } => settle_epoch(program_id, accounts, count), + Instruction::SettleEpoch { + count, + allow_below_floor, + } => settle_epoch(program_id, accounts, count, allow_below_floor), } } @@ -153,6 +164,7 @@ fn init_pool( denomination: u64, entry_fee: u64, k_floor: u32, + settle_timeout_seconds: u32, ) -> ProgramResult { let iter = &mut accounts.iter(); let payer = next_account_info(iter)?; @@ -203,7 +215,14 @@ fn init_pool( let mut data = pool_account.try_borrow_mut_data()?; let mut pool = Pool::load_uninitialised(&mut data)?; - pool.initialise(pool_bump, vault_bump, denomination, entry_fee, k_floor)?; + pool.initialise( + pool_bump, + vault_bump, + denomination, + entry_fee, + k_floor, + settle_timeout_seconds, + )?; Ok(()) } @@ -501,12 +520,19 @@ pub const SELECTOR_INVOKE: u64 = 1; /// change it. pub const SELECTOR_INVOKE_SIGNED: u64 = 2; -/// How long a spend may wait before it can settle alone. +/// How long a spend may wait before it can settle alone, when the pool did not +/// pick a figure of its own. /// /// Below the crowd size, a batch must wait this out. It is the escape valve that /// makes the crowd requirement safe: without it, a quiet pool could hold a /// member's funds indefinitely because the crowd never arrives, and a privacy /// tool that can strand your money is not one anybody should use. +/// +/// A pool may set its own at creation, within +/// [`MIN_SETTLE_TIMEOUT_SECONDS`](crate::state::MIN_SETTLE_TIMEOUT_SECONDS) and +/// [`MAX_SETTLE_TIMEOUT_SECONDS`](crate::state::MAX_SETTLE_TIMEOUT_SECONDS). An +/// hour is what it gets if it does not, which is what every pool created before +/// the field existed holds. pub const SETTLE_TIMEOUT_SECONDS: i64 = 3_600; /// Executes a batch of pending spends in one transaction. @@ -528,13 +554,30 @@ pub const SETTLE_TIMEOUT_SECONDS: i64 = 3_600; /// /// **The timeout side has no floor, and a batch of one settles.** Settlement is /// permissionless, so an adversary may be the settler and may compose the batch; -/// a spend becomes settleable alone an hour after it was submitted, whatever +/// a spend becomes settleable alone once the pool's timeout has run, whatever /// else is pending. `k_floor` bounds a batch that settles by crowd and bounds /// nothing about one that settles by clock. That is a deliberate trade of /// anonymity for solvency — the alternative freezes a quiet pool's escrow with /// no authority able to release it — and `docs/THREAT_MODEL.md` argues it rather /// than leaving it to be discovered. -fn settle_epoch(program_id: &Pubkey, accounts: &[AccountInfo], count: u8) -> ProgramResult { +/// +/// Two things make that trade visible rather than silent, and neither of them +/// changes what is permitted: +/// +/// - **It has to be asked for.** `allow_below_floor` is consent, not a bypass: +/// the timeout still has to have run. Without the flag an under-floor batch is +/// refused outright, so a settler cannot compose one by accident and cost a +/// member the anonymity set they deposited for. +/// - **It leaves a mark.** A settlement that lands below the floor logs the +/// count it carried and the floor it missed, so the difference between a crowd +/// settlement and a solo timeout settlement is readable from the chain instead +/// of having to be inferred from a count of accounts. +fn settle_epoch( + program_id: &Pubkey, + accounts: &[AccountInfo], + count: u8, + allow_below_floor: bool, +) -> ProgramResult { if count == 0 { return Err(MirrorProgramError::MalformedInstruction.into()); } @@ -554,15 +597,35 @@ fn settle_epoch(program_id: &Pubkey, accounts: &[AccountInfo], count: u8) -> Pro return Err(MirrorProgramError::InvalidPda.into()); } - let (denomination, k_floor) = { + let (denomination, k_floor, settle_timeout) = { let mut data = pool_account.try_borrow_mut_data()?; let pool = Pool::load(&mut data)?; - (pool.denomination(), pool.k_floor()) + ( + pool.denomination(), + pool.k_floor(), + // The pool's own timeout where it set one, the program's default + // where it did not. A pool created before the field existed holds + // zero, which is the absence of a choice rather than a choice of + // zero, and it keeps the hour it was created with. + pool.settle_timeout_seconds() + .map_or(SETTLE_TIMEOUT_SECONDS, i64::from), + ) }; let now = solana_program::clock::Clock::get()?.unix_timestamp; let crowd_satisfied = count as u32 >= k_floor; + // Consent, checked before a single record is read. + // + // The timeout below still governs whether an under-floor batch *may* + // settle; this governs whether anyone asked. Refusing here rather than + // deeper in means the rejection costs almost nothing and names itself: a + // settler who did not intend this gets a code that says so, instead of a + // settlement that quietly hands its members an anonymity set of one. + if !crowd_satisfied && !allow_below_floor { + return Err(MirrorProgramError::BelowFloorNotPermitted.into()); + } + // Every member in a batch must be paid the same amount, and the fee is the // only thing that can make them differ. // @@ -605,9 +668,7 @@ fn settle_epoch(program_id: &Pubkey, accounts: &[AccountInfo], count: u8) -> Pro { return Err(MirrorProgramError::InvalidPda.into()); } - if !crowd_satisfied - && now.saturating_sub(record.submitted_at()) < SETTLE_TIMEOUT_SECONDS - { + if !crowd_satisfied && now.saturating_sub(record.submitted_at()) < settle_timeout { return Err(MirrorProgramError::CrowdTooSmall.into()); } @@ -755,6 +816,32 @@ fn settle_epoch(program_id: &Pubkey, accounts: &[AccountInfo], count: u8) -> Pro if vault_account.lamports() < required { return Err(MirrorProgramError::InsolventVault.into()); } + + // The mark, and it is the last thing this instruction does. + // + // Logged only when the batch came in under the floor, so the normal path + // pays nothing for it, and logged after the settlement has actually + // succeeded, so the line cannot appear on a transaction that failed. + // + // A log rather than return data because settlement makes CPIs and every CPI + // clears the return-data slot — anything written for an observer would have + // to be written after the last invoke to survive at all, and at that point + // the log is the version that shows up where the observer is already + // looking. + // + // What it says is the whole finding: this settlement was not the crowd it + // looks like. Without it, a batch of one and a batch of `k_floor` are told + // apart only by counting accounts in the transaction, and the floor it fell + // short of is not in the transaction at all. + if !crowd_satisfied { + solana_program::msg!( + "mirror-pool: settled {} spend(s) below the pool floor of {} on the {}s timeout; \ + the effective anonymity set of this batch is its size, not the floor", + settled, + k_floor, + settle_timeout + ); + } Ok(()) } @@ -934,7 +1021,7 @@ mod tests { let mut data = vec![0u8; POOL_LEN]; { let mut pool = Pool::load_uninitialised(&mut data).unwrap(); - pool.initialise(255, 254, 1_000_000, 0, 2).unwrap(); + pool.initialise(255, 254, 1_000_000, 0, 2, 0).unwrap(); } let mut host = Frontier::new().unwrap(); let mut pool_data = data; @@ -961,7 +1048,7 @@ mod tests { let mut data = vec![0u8; POOL_LEN]; { let mut pool = Pool::load_uninitialised(&mut data).unwrap(); - pool.initialise(255, 254, 1, 0, 2).unwrap(); + pool.initialise(255, 254, 1, 0, 2, 0).unwrap(); } let mut roots = Vec::new(); for i in 1..=20u64 { diff --git a/programs/mirror-pool/src/state.rs b/programs/mirror-pool/src/state.rs index 878d5d6f..1b5408ef 100644 --- a/programs/mirror-pool/src/state.rs +++ b/programs/mirror-pool/src/state.rs @@ -38,7 +38,15 @@ mod offset { pub const SPEND_COUNT: usize = 32; pub const NEXT_INDEX: usize = 40; pub const ROOT_POS: usize = 48; - pub const _RESERVED: usize = 52; + /// Was reserved. Now the pool's own settlement timeout, in seconds. + /// + /// It lands in bytes that were already allocated and always zero, and zero + /// is read as "use the default" — so a pool created before this field + /// existed keeps the behaviour it was created with, and no account has to + /// be migrated. That is why [`POOL_VERSION`] does not move: bumping it + /// would make `Pool::load` refuse every live account, and the notes in + /// those pools are unspendable without it. + pub const SETTLE_TIMEOUT: usize = 52; pub const FILLED: usize = 56; pub const ROOTS: usize = FILLED + 32 * TREE_DEPTH; pub const END: usize = ROOTS + 32 * ROOT_HISTORY; @@ -61,6 +69,24 @@ pub const MIN_K_FLOOR: u32 = 2; /// protocol that denomination. pub const MAX_K_FLOOR: u32 = 1 << TREE_DEPTH; +/// Shortest settlement timeout a pool may choose. +/// +/// The timeout is the escape valve that keeps a quiet pool solvent, and a very +/// short one turns it into the ordinary path: every spend becomes settleable +/// alone almost immediately, which leaves `k_floor` bounding nothing at all. A +/// minute is short enough to be exercised in a test and long enough that +/// reaching the valve is still an event. +pub const MIN_SETTLE_TIMEOUT_SECONDS: u32 = 60; + +/// Longest settlement timeout a pool may choose. +/// +/// Bounded for the same reason `k_floor` is: the value is fixed at creation, +/// pool creation is permissionless, and a pool is unique per denomination +/// forever. An unbounded timeout would let anyone deny a denomination by +/// creating its pool with a valve that never opens — the deposits would be +/// escrowed with no crowd and no clock able to release them. +pub const MAX_SETTLE_TIMEOUT_SECONDS: u32 = 7 * 24 * 60 * 60; + // Pin the layout. A field inserted in the middle would otherwise silently // reinterpret every deployed account. const _: () = assert!(offset::FILLED == 56); @@ -145,6 +171,21 @@ impl<'a> Pool<'a> { b.copy_from_slice(&self.data[offset::K_FLOOR..offset::K_FLOOR + 4]); u32::from_le_bytes(b) } + /// The pool's own settlement timeout, or `None` when it never set one. + /// + /// `None` rather than a substituted default on purpose: the stored bytes + /// say what the pool holds, and what an absent value *means* is settlement + /// policy, which lives in the processor. A reader of this accessor can tell + /// "this pool chose an hour" from "this pool chose nothing", and those are + /// different facts even when they produce the same number. + pub fn settle_timeout_seconds(&self) -> Option { + let mut b = [0u8; 4]; + b.copy_from_slice(&self.data[offset::SETTLE_TIMEOUT..offset::SETTLE_TIMEOUT + 4]); + match u32::from_le_bytes(b) { + 0 => None, + v => Some(v), + } + } pub fn deposit_count(&self) -> u64 { read_u64!(self, offset::DEPOSIT_COUNT) } @@ -170,10 +211,21 @@ impl<'a> Pool<'a> { denomination: u64, entry_fee: u64, k_floor: u32, + settle_timeout_seconds: u32, ) -> Result<(), MirrorProgramError> { if denomination == 0 { return Err(MirrorProgramError::InvalidParameter); } + // Zero is not a timeout, it is the absence of one, and the processor + // reads it as the default. Any other value has to be a timeout somebody + // could live with, for the same reason `k_floor` has to be reachable: + // the parameter is immutable and the pool is permanent. + if settle_timeout_seconds != 0 + && !(MIN_SETTLE_TIMEOUT_SECONDS..=MAX_SETTLE_TIMEOUT_SECONDS) + .contains(&settle_timeout_seconds) + { + return Err(MirrorProgramError::InvalidParameter); + } // The floor must be reachable, and it must actually be a crowd. // // Pool creation is permissionless and a pool is unique per denomination @@ -216,6 +268,8 @@ impl<'a> Pool<'a> { write_u64!(self, offset::DENOMINATION, denomination); write_u64!(self, offset::ENTRY_FEE, entry_fee); self.data[offset::K_FLOOR..offset::K_FLOOR + 4].copy_from_slice(&k_floor.to_le_bytes()); + self.data[offset::SETTLE_TIMEOUT..offset::SETTLE_TIMEOUT + 4] + .copy_from_slice(&settle_timeout_seconds.to_le_bytes()); // An empty tree's frontier is the zero ladder, and its root is the top // of that ladder. Seeding from the shared constant is what keeps the @@ -336,7 +390,7 @@ mod tests { let mut data = blank(); { let mut pool = Pool::load_uninitialised(&mut data).unwrap(); - pool.initialise(254, 253, denomination, 0, 4).unwrap(); + pool.initialise(254, 253, denomination, 0, 4, 0).unwrap(); } data } @@ -353,6 +407,79 @@ mod tests { assert_eq!(pool.deposit_count(), 0); assert_eq!(pool.spend_count(), 0); assert_eq!(pool.next_index(), 0); + assert_eq!(pool.settle_timeout_seconds(), None); + } + + /// Zero means "chose nothing", and that is the state of every pool created + /// before the field existed. + /// + /// This is the compatibility claim the layout rests on: the bytes at + /// `SETTLE_TIMEOUT` were reserved and therefore zero in every live account, + /// so a deployed pool reads back `None` and keeps the timeout it was created + /// with. If this test fails, upgrading the program silently changes the + /// settlement rules of pools that already hold other people's money. + #[test] + fn a_pool_that_predates_the_field_reads_back_no_timeout() { + let mut data = initialised(1_000); + assert_eq!( + Pool::load(&mut data).unwrap().settle_timeout_seconds(), + None + ); + // And the bytes really are the reserved ones, untouched. + assert_eq!( + &data[offset::SETTLE_TIMEOUT..offset::SETTLE_TIMEOUT + 4], + &[0u8; 4] + ); + } + + #[test] + fn a_pool_reads_back_the_timeout_it_chose() { + let mut data = blank(); + { + let mut pool = Pool::load_uninitialised(&mut data).unwrap(); + pool.initialise(254, 253, 1_000, 0, 4, 900).unwrap(); + } + let pool = Pool::load(&mut data).unwrap(); + assert_eq!(pool.settle_timeout_seconds(), Some(900)); + // The neighbouring fields must not have moved. + assert_eq!(pool.k_floor(), 4); + assert_eq!(pool.denomination(), 1_000); + assert_eq!( + pool.current_root().unwrap().to_bytes(), + ZERO_LADDER[TREE_DEPTH] + ); + } + + /// Bounded for the same reason the floor is: the value is immutable, pool + /// creation is permissionless, and a pool is unique per denomination + /// forever. A timeout that never elapses would escrow deposits with no crowd + /// and no clock able to release them. + #[test] + fn a_settlement_timeout_outside_its_bounds_is_refused() { + let mut data = blank(); + let mut pool = Pool::load_uninitialised(&mut data).unwrap(); + for bad in [ + 1, + MIN_SETTLE_TIMEOUT_SECONDS - 1, + MAX_SETTLE_TIMEOUT_SECONDS + 1, + u32::MAX, + ] { + assert!( + matches!( + pool.initialise(1, 1, 1_000, 0, 4, bad), + Err(MirrorProgramError::InvalidParameter) + ), + "a timeout of {bad}s must be refused" + ); + } + for good in [0, MIN_SETTLE_TIMEOUT_SECONDS, MAX_SETTLE_TIMEOUT_SECONDS] { + let mut fresh = blank(); + let mut pool = Pool::load_uninitialised(&mut fresh).unwrap(); + assert!( + pool.initialise(1, 1, 1_000, 0, 4, good).is_ok(), + "a timeout of {good}s must be accepted" + ); + } } /// The immutable reader must agree with the borrowing accessor, and must @@ -440,11 +567,11 @@ mod tests { let mut data = blank(); let mut pool = Pool::load_uninitialised(&mut data).unwrap(); assert!(matches!( - pool.initialise(1, 1, 0, 0, 4), + pool.initialise(1, 1, 0, 0, 4, 0), Err(MirrorProgramError::InvalidParameter) )); assert!(matches!( - pool.initialise(1, 1, 100, 0, 0), + pool.initialise(1, 1, 100, 0, 0, 0), Err(MirrorProgramError::InvalidParameter) )); } @@ -458,14 +585,14 @@ mod tests { let mut data = blank(); let mut pool = Pool::load_uninitialised(&mut data).unwrap(); assert!(matches!( - pool.initialise(1, 1, 1_000_000, 0, u32::MAX), + pool.initialise(1, 1, 1_000_000, 0, u32::MAX, 0), Err(MirrorProgramError::InvalidParameter) )); assert!(matches!( - pool.initialise(1, 1, 1_000_000, 0, MAX_K_FLOOR + 1), + pool.initialise(1, 1, 1_000_000, 0, MAX_K_FLOOR + 1, 0), Err(MirrorProgramError::InvalidParameter) )); - assert!(pool.initialise(1, 1, 1_000_000, 0, MAX_K_FLOOR).is_ok()); + assert!(pool.initialise(1, 1, 1_000_000, 0, MAX_K_FLOOR, 0).is_ok()); } #[test] @@ -473,10 +600,10 @@ mod tests { let mut data = blank(); let mut pool = Pool::load_uninitialised(&mut data).unwrap(); assert!(matches!( - pool.initialise(1, 1, 1_000_000, 0, 1), + pool.initialise(1, 1, 1_000_000, 0, 1, 0), Err(MirrorProgramError::InvalidParameter) )); - assert!(pool.initialise(1, 1, 1_000_000, 0, MIN_K_FLOOR).is_ok()); + assert!(pool.initialise(1, 1, 1_000_000, 0, MIN_K_FLOOR, 0).is_ok()); } /// A nonzero entry fee is unrecoverable, so it is refused at creation @@ -494,13 +621,13 @@ mod tests { for fee in [1, 999, 1_000, u64::MAX] { assert!( matches!( - pool.initialise(1, 1, 1_000, fee, 4), + pool.initialise(1, 1, 1_000, fee, 4, 0), Err(MirrorProgramError::InvalidParameter) ), "entry fee {fee} must be refused: nothing can pay it out" ); } - assert!(pool.initialise(1, 1, 1_000, 0, 4).is_ok()); + assert!(pool.initialise(1, 1, 1_000, 0, 4, 0).is_ok()); } #[test] diff --git a/programs/mirror-pool/tests/action_ceiling.rs b/programs/mirror-pool/tests/action_ceiling.rs index 85afbe52..95808bec 100644 --- a/programs/mirror-pool/tests/action_ceiling.rs +++ b/programs/mirror-pool/tests/action_ceiling.rs @@ -32,7 +32,7 @@ //! program's real instruction encoding rather than from arithmetic, but no batch //! of that shape was run, so it is labelled `size only` and carries no compute //! figure. The transfer row is the control: `batch_ceiling.rs` already settles it -//! against the same `.so`, so this file recomputing 10 spends / 1228 bytes is a +//! against the same `.so`, so this file recomputing 10 spends / 1229 bytes is a //! check on the encoder rather than a new claim. If that row ever moves, the //! model of the wire here is wrong and every other number it prints is suspect. //! @@ -121,19 +121,19 @@ const PRE_STAKED: u64 = 1_000_000_000; /// The plain-transfer ceiling, recomputed here from the encoder and cross-checked /// against the executed measurement in `batch_ceiling.rs`. const TRANSFER_CEILING: usize = 10; -const TRANSFER_BYTES: usize = 1228; +const TRANSFER_BYTES: usize = 1229; /// A batch of members all delegating to the **same** validator. Per spend the /// distinct keys are the record, the stake account and the relay; the vote /// account, the two sysvars, the config account, the stake program and the pool's /// vault are named once for the whole batch. const SAME_VALIDATOR_CEILING: usize = 7; -const SAME_VALIDATOR_BYTES: usize = 1140; +const SAME_VALIDATOR_BYTES: usize = 1141; /// The same delegation where every member picked their own validator. One more /// distinct key per spend, and the crowd loses a member. const DIFFERENT_VALIDATOR_CEILING: usize = 6; -const DIFFERENT_VALIDATOR_BYTES: usize = 1194; +const DIFFERENT_VALIDATOR_BYTES: usize = 1195; fn key(s: &str) -> Pubkey { s.parse().expect("a valid base58 address") @@ -210,6 +210,11 @@ fn settle_ix(wire: &Wire, batch: &[Spend], settler: &Pubkey) -> Instruction { wire.program_id, &MirrorIx::SettleEpoch { count: batch.len() as u8, + // This file measures the wire and the lock limit, and the sweep + // starts below the pool's floor. Asking for the under-floor path + // keeps the crowd rule from being what stops a batch here, so the + // ceiling reported is the one the transaction imposes. + allow_below_floor: true, } .pack(), metas, @@ -428,6 +433,7 @@ impl Env { denomination: DENOMINATION, entry_fee: ENTRY_FEE, k_floor: K_FLOOR, + settle_timeout_seconds: 0, } .pack(), vec![ diff --git a/programs/mirror-pool/tests/batch_ceiling.rs b/programs/mirror-pool/tests/batch_ceiling.rs index ce566296..00f18c82 100644 --- a/programs/mirror-pool/tests/batch_ceiling.rs +++ b/programs/mirror-pool/tests/batch_ceiling.rs @@ -76,7 +76,14 @@ const DEFAULT_COMPUTE_BUDGET: u64 = 200_000; const MAX_SPENDS_PER_SETTLEMENT: usize = 10; /// The wire size of that settlement. There is no room for an eleventh spend and /// not much room for anything else either. -const BYTES_AT_MAX: usize = 1228; +/// +/// It was 1228 until `settle_epoch` gained the below-floor flag, which costs one +/// byte of instruction data. The *ceiling* did not move — a spend costs three +/// account keys, ninety-six bytes, so one byte was never going to buy or lose a +/// member — and that separation is why both numbers are pinned rather than one: +/// this figure moving alone means the encoding changed, and the other moving +/// means the crowd did. +const BYTES_AT_MAX: usize = 1229; fn program_bytes() -> Vec { let path = concat!( @@ -134,6 +141,7 @@ impl Env { denomination: DENOMINATION, entry_fee: ENTRY_FEE, k_floor: K_FLOOR, + settle_timeout_seconds: 0, } .pack(), vec![ @@ -223,6 +231,11 @@ fn settle_ix(env: &Env, batch: &[Party], settler: &Pubkey) -> Instruction { env.program_id, &MirrorIx::SettleEpoch { count: batch.len() as u8, + // This file measures the wire and the lock limit, and the sweep + // starts below the pool's floor. Asking for the under-floor path + // keeps the crowd rule from being what stops a batch here, so the + // ceiling reported is the one the transaction imposes. + allow_below_floor: true, } .pack(), metas, diff --git a/programs/mirror-pool/tests/end_to_end.rs b/programs/mirror-pool/tests/end_to_end.rs index 6a7fd32a..19ce2562 100644 --- a/programs/mirror-pool/tests/end_to_end.rs +++ b/programs/mirror-pool/tests/end_to_end.rs @@ -110,6 +110,24 @@ impl Env { }) } + /// Sends and hands back the metadata, for the tests whose subject is what + /// the program *said* rather than only whether it succeeded. + /// + /// A log line is program output like any other: asserting it from the + /// transaction's own metadata is what makes "settlement leaves a mark" a + /// checked claim rather than a sentence in a document. + fn send_meta( + &mut self, + ix: Instruction, + signer: &Keypair, + ) -> Result { + let msg = Message::new(&[ix], Some(&signer.pubkey())); + let tx = Transaction::new(&[signer], msg, self.svm.latest_blockhash()); + self.svm + .send_transaction(tx) + .map_err(|e| format!("{:?} | logs: {:#?}", e.err, e.meta.logs)) + } + fn send_expect_cu(&mut self, ix: Instruction, signer: &Keypair) -> u64 { let msg = Message::new(&[ix], Some(&signer.pubkey())); let tx = Transaction::new(&[signer], msg, self.svm.latest_blockhash()); @@ -119,13 +137,17 @@ impl Env { meta.compute_units_consumed } - fn init_pool(&mut self) { + /// Creates the pool. Zero takes the program's default timeout, which is + /// what every pool created before the field existed holds and what the rest + /// of this suite exercises. + fn init_pool_with_timeout(&mut self, settle_timeout_seconds: u32) { let ix = Instruction::new_with_bytes( self.program_id, &MirrorIx::InitPool { denomination: DENOMINATION, entry_fee: ENTRY_FEE, k_floor: K_FLOOR, + settle_timeout_seconds, } .pack(), vec![ @@ -171,8 +193,12 @@ impl Env { /// Builds a pool with `count` deposits and returns the host-side tree plus the /// notes, so a proof can be produced for any of them. fn seeded_pool(count: u64) -> (Env, MerkleTree, Vec, Keys) { + seeded_pool_with_timeout(count, 0) +} + +fn seeded_pool_with_timeout(count: u64, timeout: u32) -> (Env, MerkleTree, Vec, Keys) { let mut env = setup(); - env.init_pool(); + env.init_pool_with_timeout(timeout); let denom_tag = Field::from_u64(DENOMINATION); let mut tree = MerkleTree::new().unwrap(); @@ -327,7 +353,7 @@ fn proof_for( #[test] fn a_pool_initialises_with_an_empty_accumulator() { let mut env = setup(); - env.init_pool(); + env.init_pool_with_timeout(0); let mut data = env.pool_state(); assert_eq!(data.len(), POOL_LEN); @@ -639,8 +665,22 @@ fn submit_batch( out } +/// Settlement that asks for the crowd, which is what an ordinary settler sends. +/// +/// The below-floor flag is deliberately *not* a default anywhere in this suite: +/// a test that settles under the floor has to say so, exactly as a settler does. +/// That is what keeps the two paths from being confused for one another here. fn settle_ix(env: &Env, batch: &[(Pubkey, Pubkey, Keypair)], settler: &Pubkey) -> Instruction { - settle_ix_with_targets(env, batch, settler, &[]) + settle_ix_with_targets(env, batch, settler, &[], false) +} + +/// Settlement that consents to a batch below the pool's floor. +fn settle_ix_below_floor( + env: &Env, + batch: &[(Pubkey, Pubkey, Keypair)], + settler: &Pubkey, +) -> Instruction { + settle_ix_full(env, batch, settler, &[], &[], true) } /// Settlement where some spends invoke a program. `targets[i]`, when present, @@ -651,8 +691,9 @@ fn settle_ix_with_targets( batch: &[(Pubkey, Pubkey, Keypair)], settler: &Pubkey, targets: &[Option], + allow_below_floor: bool, ) -> Instruction { - settle_ix_full(env, batch, settler, targets, &[]) + settle_ix_full(env, batch, settler, targets, &[], allow_below_floor) } /// Settlement carrying, per spend, an optional target program and that action's @@ -663,6 +704,7 @@ fn settle_ix_full( settler: &Pubkey, targets: &[Option], action_accounts: &[Vec], + allow_below_floor: bool, ) -> Instruction { let mut metas = vec![ AccountMeta::new(*settler, true), @@ -684,6 +726,7 @@ fn settle_ix_full( env.program_id, &MirrorIx::SettleEpoch { count: batch.len() as u8, + allow_below_floor, } .pack(), metas, @@ -800,7 +843,10 @@ fn a_batch_below_the_crowd_size_must_wait() { let settler = Keypair::new(); env.svm.airdrop(&settler.pubkey(), 10_000_000_000).unwrap(); - let ix = settle_ix(&env, &batch, &settler.pubkey()); + // Asking for the under-floor path explicitly, so that what this test + // measures is the clock and not the consent — the two refusals carry + // different codes and a test that accepted either would prove neither. + let ix = settle_ix_below_floor(&env, &batch, &settler.pubkey()); let err = env .send(ix, &settler) .expect_err("a batch below the crowd size settled immediately"); @@ -811,6 +857,171 @@ fn a_batch_below_the_crowd_size_must_wait() { ); } +/// Consent, and the fact that it is not a bypass. +/// +/// The flag and the timeout are independent gates and this pins both: an +/// under-floor batch without the flag is refused whatever the clock says, and +/// the flag on its own does not buy a settlement the clock has not earned. +#[test] +fn an_under_floor_batch_is_refused_unless_the_settler_asked_for_it() { + let (mut env, tree, notes, keys) = seeded_pool(6); + let batch = submit_batch(&mut env, &tree, ¬es, &keys, K_FLOOR as usize - 1); + + // A settler per attempt. Byte-identical transactions are deduplicated by + // the runtime before the program runs, and a negative case that never + // reached the program asserts nothing about it. + let early = Keypair::new(); + let late = Keypair::new(); + let consenting = Keypair::new(); + for k in [&early, &late, &consenting] { + env.svm.airdrop(&k.pubkey(), 10_000_000_000).unwrap(); + } + + // No flag, before the timeout: refused for want of consent, and the code + // says which of the two gates closed. + let err = env + .send(settle_ix(&env, &batch, &early.pubkey()), &early) + .expect_err("an under-floor batch settled without the flag"); + println!("under-floor batch without consent rejected: {err}"); + assert!( + err.contains("Custom(26)"), + "expected BelowFloorNotPermitted (26), got: {err}" + ); + + // No flag, *after* the timeout: still refused. The clock does not imply + // consent, which is the whole point of asking for it. + let mut clock = env.svm.get_sysvar::(); + clock.unix_timestamp += mirror_pool_program::processor::SETTLE_TIMEOUT_SECONDS + 1; + env.svm.set_sysvar(&clock); + let err = env + .send(settle_ix(&env, &batch, &late.pubkey()), &late) + .expect_err("the timeout was read as consent"); + assert!( + err.contains("Custom(26)"), + "expected BelowFloorNotPermitted (26) after the timeout, got: {err}" + ); + + // With the flag, now that the clock has run: settles. + env.send( + settle_ix_below_floor(&env, &batch, &consenting.pubkey()), + &consenting, + ) + .expect("an under-floor batch with consent and an elapsed timeout"); +} + +/// A crowd-sized batch ignores the flag entirely. +/// +/// Worth pinning because the alternative reading — that the flag is a mode the +/// settler puts the instruction into — would make an ordinary settlement behave +/// differently depending on a byte that should not matter to it. +#[test] +fn the_below_floor_flag_changes_nothing_for_a_batch_that_meets_the_floor() { + for asked in [false, true] { + let (mut env, tree, notes, keys) = seeded_pool(6); + let batch = submit_batch(&mut env, &tree, ¬es, &keys, K_FLOOR as usize); + let settler = Keypair::new(); + env.svm.airdrop(&settler.pubkey(), 10_000_000_000).unwrap(); + + let ix = settle_ix_full(&env, &batch, &settler.pubkey(), &[], &[], asked); + let meta = env.send_meta(ix, &settler).unwrap_or_else(|e| { + panic!("a full crowd was refused with allow_below_floor={asked}: {e}") + }); + + // And no mark: this batch was the crowd it claimed to be. + assert!( + !meta.logs.iter().any(|l| l.contains("below the pool floor")), + "a full crowd logged the under-floor marker with allow_below_floor={asked}: {:#?}", + meta.logs + ); + } +} + +/// A pool settles on its own clock, not the program's. +/// +/// The default is an hour; this pool asked for a minute. Both halves matter: the +/// spend must not settle before its own timeout, and it must settle after it +/// rather than waiting out an hour it never agreed to. A program that read the +/// constant instead of the account would pass the first half and fail the +/// second, which is the failure this pins. +#[test] +fn a_pool_settles_on_the_timeout_it_chose_rather_than_the_default() { + const CHOSEN: u32 = mirror_pool_program::state::MIN_SETTLE_TIMEOUT_SECONDS; // 60s + let (mut env, tree, notes, keys) = seeded_pool_with_timeout(6, CHOSEN); + let batch = submit_batch(&mut env, &tree, ¬es, &keys, 1); + + let early = Keypair::new(); + let late = Keypair::new(); + for k in [&early, &late] { + env.svm.airdrop(&k.pubkey(), 10_000_000_000).unwrap(); + } + + // A second before its own timeout: refused. + let mut clock = env.svm.get_sysvar::(); + clock.unix_timestamp += i64::from(CHOSEN) - 1; + env.svm.set_sysvar(&clock); + let err = env + .send(settle_ix_below_floor(&env, &batch, &early.pubkey()), &early) + .expect_err("settled before the pool's own timeout"); + assert!( + err.contains("Custom(21)"), + "expected CrowdTooSmall (21) before the chosen timeout, got: {err}" + ); + + // A second after it, and still far short of the program's default hour. + clock.unix_timestamp += 2; + env.svm.set_sysvar(&clock); + let meta = env + .send_meta(settle_ix_below_floor(&env, &batch, &late.pubkey()), &late) + .expect("a lone spend after the pool's own timeout"); + + // And the mark names the pool's figure, not the default. + let mark = meta + .logs + .iter() + .find(|l| l.contains("below the pool floor")) + .unwrap_or_else(|| panic!("no under-floor mark: {:#?}", meta.logs)); + assert!( + mark.contains(&format!("on the {CHOSEN}s timeout")), + "the mark quotes a timeout this pool did not choose: {mark}" + ); +} + +/// The mark R1 asks for: a settlement that lands below the floor says so on +/// chain, so a crowd settlement and a solo timeout settlement are not the same +/// transaction shape read two ways. +#[test] +fn a_settlement_below_the_floor_leaves_a_mark_naming_the_floor_it_missed() { + let (mut env, tree, notes, keys) = seeded_pool(6); + let batch = submit_batch(&mut env, &tree, ¬es, &keys, 1); + + let settler = Keypair::new(); + env.svm.airdrop(&settler.pubkey(), 10_000_000_000).unwrap(); + let mut clock = env.svm.get_sysvar::(); + clock.unix_timestamp += mirror_pool_program::processor::SETTLE_TIMEOUT_SECONDS + 1; + env.svm.set_sysvar(&clock); + + let meta = env + .send_meta( + settle_ix_below_floor(&env, &batch, &settler.pubkey()), + &settler, + ) + .expect("a lone spend after the timeout"); + + let mark = meta + .logs + .iter() + .find(|l| l.contains("below the pool floor")) + .unwrap_or_else(|| panic!("no under-floor mark in the logs: {:#?}", meta.logs)); + println!("{mark}"); + // Both numbers, because either alone is unreadable: a count with no floor + // does not say it fell short, and a floor with no count does not say by how + // much. + assert!( + mark.contains("settled 1 spend(s)") && mark.contains("floor of 4"), + "the mark does not carry both the count and the floor: {mark}" + ); +} + /// The escape valve. A quiet pool must not hold a member's funds forever, so /// once the timeout has passed a lone spend settles on its own. #[test] @@ -824,7 +1035,7 @@ fn a_lone_spend_settles_once_the_timeout_has_passed() { env.svm.airdrop(&late.pubkey(), 10_000_000_000).unwrap(); // Before the timeout: refused. - let ix = settle_ix(&env, &batch, &early.pubkey()); + let ix = settle_ix_below_floor(&env, &batch, &early.pubkey()); assert!(env.send(ix, &early).is_err(), "settled too early"); // Advance the clock past the timeout. @@ -837,7 +1048,7 @@ fn a_lone_spend_settles_once_the_timeout_has_passed() { .get_account(&batch[0].1) .map(|a| a.lamports) .unwrap_or(0); - let ix = settle_ix(&env, &batch, &late.pubkey()); + let ix = settle_ix_below_floor(&env, &batch, &late.pubkey()); env.send(ix, &late).expect("lone spend after timeout"); let after = env.svm.get_account(&batch[0].1).unwrap().lamports; assert_eq!(after - before, DENOMINATION - RELAY_FEE); @@ -943,7 +1154,7 @@ fn a_member_can_always_exit_without_any_relay() { let before = env.svm.get_account(&member.pubkey()).unwrap().lamports; let batch = vec![(spend_pda, beneficiary, member.insecure_clone())]; - let settle = settle_ix(&env, &batch, &member.pubkey()); + let settle = settle_ix_below_floor(&env, &batch, &member.pubkey()); env.send(settle, &member) .expect("member settled their own spend"); @@ -1197,7 +1408,7 @@ fn a_crowd_of_members_perform_a_real_protocol_action_together() { let settler = Keypair::new(); env.svm.airdrop(&settler.pubkey(), 10_000_000_000).unwrap(); let targets: Vec> = batch.iter().map(|_| Some(memo)).collect(); - let ix = settle_ix_with_targets(&env, &batch, &settler.pubkey(), &targets); + let ix = settle_ix_with_targets(&env, &batch, &settler.pubkey(), &targets, false); let cu = env.send_expect_cu(ix, &settler); println!( "settled {} real CPI actions in one transaction, {cu} CU", @@ -1289,6 +1500,7 @@ fn an_action_runs_with_a_non_empty_account_list() { &settler.pubkey(), &[Some(memo)], &[vec![AccountMeta::new_readonly(settler.pubkey(), true)]], + true, ); let cu = env.send_expect_cu(ix, &settler); println!("settled a signed CPI action, {cu} CU"); @@ -1348,7 +1560,14 @@ fn a_settler_cannot_change_how_many_accounts_an_action_gets() { // one account past this spend's own, so it consumes something that is not // there and fails rather than invoking with a truncated list. let batch = vec![(spend_pda, beneficiary, relay.insecure_clone())]; - let ix = settle_ix_full(&env, &batch, &settler.pubkey(), &[Some(memo)], &[vec![]]); + let ix = settle_ix_full( + &env, + &batch, + &settler.pubkey(), + &[Some(memo)], + &[vec![]], + true, + ); let err = env .send(ix, &settler) .expect_err("settlement invoked with fewer accounts than declared"); @@ -1420,6 +1639,7 @@ fn a_settler_cannot_place_the_vault_in_an_action_account_list() { &settler.pubkey(), &[Some(memo)], &[vec![AccountMeta::new(env.vault, false)]], + true, ); let err = env .send(ix, &settler) @@ -1498,6 +1718,7 @@ fn the_pool_signs_an_action_as_its_own_authority() { &settler.pubkey(), &[Some(memo)], &[vec![AccountMeta::new(env.vault, false)]], + true, ); let msg = Message::new(&[ix], Some(&settler.pubkey())); let tx = Transaction::new(&[&settler], msg, env.svm.latest_blockhash()); @@ -1599,7 +1820,14 @@ fn a_signed_action_settles_inside_a_batch_of_plain_transfers() { let settler = Keypair::new(); env.svm.airdrop(&settler.pubkey(), 10_000_000_000).unwrap(); let vault_before = env.vault_lamports(); - let ix = settle_ix_full(&env, &batch, &settler.pubkey(), &targets, &action_accounts); + let ix = settle_ix_full( + &env, + &batch, + &settler.pubkey(), + &targets, + &action_accounts, + false, + ); let msg = Message::new(&[ix], Some(&settler.pubkey())); let tx = Transaction::new(&[&settler], msg, env.svm.latest_blockhash()); let meta = env @@ -1704,6 +1932,7 @@ fn the_pools_signature_cannot_be_turned_against_its_own_vault() { AccountMeta::new(env.vault, false), AccountMeta::new(attacker, false), ]], + true, ); let err = env .send(ix, &settler) @@ -1842,7 +2071,8 @@ fn an_action_cannot_re_enter_the_pool() { env.svm.set_sysvar(&clock); let batch = vec![(spend_pda, beneficiary, relay.insecure_clone())]; - let ix = settle_ix_with_targets(&env, &batch, &settler.pubkey(), &[Some(self_target)]); + // Alone after the timeout, so the under-floor path has to be asked for. + let ix = settle_ix_with_targets(&env, &batch, &settler.pubkey(), &[Some(self_target)], true); let err = env.send(ix, &settler).expect_err("the pool invoked itself"); println!("self-invocation rejected: {err}"); assert!( @@ -2052,7 +2282,7 @@ fn squatting_a_pool_pda_does_not_prevent_the_pool() { env.send(squat, &squatter).expect("squatting"); } - env.init_pool(); + env.init_pool_with_timeout(0); let mut data = env.pool_state(); let pool = Pool::load(&mut data).unwrap(); assert_eq!(pool.denomination(), DENOMINATION);