Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions crates/mirror-cli/src/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> {
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"))
}
}
85 changes: 81 additions & 4 deletions crates/mirror-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PoolState> {
Expand All @@ -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,
),
})
}

Expand All @@ -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);
Expand All @@ -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.
Expand All @@ -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(
Expand All @@ -115,6 +147,7 @@ pub fn init_pool(
denomination,
entry_fee: 0,
k_floor,
settle_timeout_seconds,
}
.pack(),
vec![
Expand All @@ -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(())
}
Expand Down Expand Up @@ -211,13 +252,19 @@ pub fn tree(chain: &Chain, program_id: &Pubkey, denomination: u64) -> Result<His
println!("vault {}", state.vault);
println!("denomination {}", state.denomination);
println!("k floor {}", state.k_floor);
println!("timeout {}s", state.settle_timeout);
println!(
"notes {} deposited, {} settled, {} outstanding",
state.deposits,
state.spends,
state.deposits.saturating_sub(state.spends)
);
println!();
// The nominal figures above are the ones a reader walks away with, so the
// measured discount travels with them rather than living only in a
// document nobody has open.
println!("{}", mirror_provenance::PUBLISHED_HEADLINE.note());
println!();
println!("rebuilding the accumulator from chain history:");

let history = history::scan(chain, program_id, &state.pool, true)?;
Expand Down Expand Up @@ -449,6 +496,7 @@ pub fn settle(
denomination: u64,
settler: &Keypair,
now: i64,
allow_below_floor: bool,
) -> Result<()> {
let state = read_pool(chain, program_id, denomination)?;
println!("looking for spends waiting to settle:");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(())
}

Expand Down
Loading
Loading