Skip to content
Draft
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
17 changes: 17 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@ jobs:
env:
RUSTFLAGS: ${{ matrix.rustflags }}
SIGNERS_CACHE_DIR: ${{ github.workspace }}/.signers-cache
# `lean_multisig_api`'s two `#[ignore]`d tests prove real 1500- and 1501-signature batches,
# which is the only check that `plan::LEAF_TARGET` is a leaf size the prover accepts. Without
# them a bad constant means every aggregation past 1500 signers fails in production while the
# default suite stays green — its largest single node holds three signatures.
#
# Scoped to one test binary rather than `--include-ignored` across the workspace, which would
# also drag in six unrelated ignored tests, several of them benchmarks.
#
# ~12s here: the step above already generates or loads the 10,000-signer cache (non-ignored
# tests in `tests/test_multisignatures.rs` call `get_benchmark_signatures`), so the marginal
# cost is the proving alone.
- name: Ignored slow tests
if: ${{ matrix.run_tests == true }}
run: cargo test --release -p lean_multisig_api --test round_trip --verbose -- --ignored
env:
RUSTFLAGS: ${{ matrix.rustflags }}
SIGNERS_CACHE_DIR: ${{ github.workspace }}/.signers-cache

cargo-clippy:
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
/docs/benchmark_graphs/.venv
minimal_zkVM.synctex.gz
.claude
misc/.build
misc/.build
/.worktrees
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ serde = { version = "1.0.228", features = ["derive"] }
tracing-subscriber = { version = "0.3.23", features = ["std", "env-filter"] }
tracing-forest = { version = "0.3.0", features = ["ansi", "smallvec"] }
postcard = { version = "1.1.3", features = ["alloc"] }
sha2 = "0.10.9"
ssz = { package = "ethereum_ssz", version = "0.10" }
include_dir = "0.7"
libc = "0.2"
Expand Down
20 changes: 20 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@
- Rewrite the compiler, it's bad right now.
- double check single-message / multi-message dispatch, and try to simplify the various data layouts

## Tooling

- The clippy config never reaches the member crates. Root `Cargo.toml` has `[lints.clippy]`
(`all`/`nursery`/`pedantic` at warn, plus the `allow` list), but that is a *package*-level
table, so it applies only to the root `lean-multisig` package. `[workspace.lints]` carries
just the `rust.*` and `rustdoc.*` keys, so every crate under `crates/` writing
`[lints] workspace = true` inherits those alone and gets no clippy nursery/pedantic.
Moving the table to `[workspace.lints.clippy]` would fix it, but surfaces a backlog across
the 19 member crates, so it wants doing deliberately rather than as a drive-by.

- `#[ignore]` carries two meanings, so CI selects slow tests by naming a binary. It marks both
"this is a benchmark, never run it in CI" (`benchmark_poseidons.rs`, `benchmark.rs`,
`grinding.rs`, `wots.rs`, `quotient_gkr`, `test_zkvm.rs`) and "this is a real test, too slow
for a local run, but CI must run it" (`lean_multisig_api`'s two `LEAF_TARGET` boundary tests).
Because `--include-ignored` cannot tell them apart, `rust.yml`'s `Ignored slow tests` step
names one test binary explicitly. That is correct today but is a hand-maintained allowlist:
the next ignored-but-required test is silently not run, with nothing failing to say so.
Distinguishing them — keep `#[ignore]` for benchmarks, gate slow-but-required tests behind a
`slow-tests` feature — would let CI run one command with no per-binary list.

# Ideas

- About range checks, that can currently be done in 3 cycles (see 2.5.3 of the zkVM pdf) + 3 memory cells used. For small ranges we can save 2 memory cells.
Expand Down
42 changes: 40 additions & 2 deletions crates/backend/poly/src/eq_mle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1021,8 +1021,14 @@ fn base_eval_eq_packed_with_packed_output<F, EF, const INITIALIZED: bool>(
F: Field,
EF: ExtensionField<F>,
{
// `eval_points` is the middle slice from `par_eval_eq`, so its length says nothing about the
// packing width (the callers assert that against the full point).
// Ensure that the output buffer size is correct:
// It should be of size `2^n`, where `n` is the number of variables.
//
// `eval_points` is the *middle* slice handed over by `par_eval_eq`, not the full point:
// the `log_packing_width` suffix is already folded into `eq_evals` and the `log_chunks`
// prefix into `packed_scalar`. Its length is therefore unrelated to the packing width,
// and asserting `log_packing_width <= eval_points.len()` here is wrong — that invariant
// belongs to the callers, which check it against the *full* point.
debug_assert_eq!(out.len(), 1 << eval_points.len());

match eval_points.len() {
Expand Down Expand Up @@ -1317,6 +1323,38 @@ mod tests {
}
}

/// `base_eval_eq_packed_with_packed_output` receives the *middle* slice of the eval

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been pulled into a separate PR

/// points: `par_eval_eq` strips a `log_chunks` prefix and a `log_packing_width` suffix,
/// leaving `n - log_packing_width - log_chunks` variables. The packed path only requires
/// that to be at least 2, so the middle slice is routinely *shorter* than
/// `log_packing_width` and the kernel must not assume otherwise.
///
/// This covers the narrow band of `n_vars` just above the packed-path threshold, where
/// that happens. Both the assertion and the band are machine-dependent (they move with
/// the thread count and SIMD width), so the bounds are computed rather than hardcoded.
#[test]
fn base_packed_handles_middle_slice_shorter_than_packing_width() {
let log_packing_width = log2_strict_usize(<F as Field>::Packing::WIDTH);
let (log_chunks, _) = parallel_split();
let mut rng = StdRng::seed_from_u64(11);

// Lower bound: first `n_vars` taking the packed path (see `compute_eval_eq_base_packed`).
// Upper bound: first `n_vars` whose middle slice reaches `log_packing_width`.
for n_vars in (log_packing_width + log_chunks + 2)..=(2 * log_packing_width + log_chunks) {
let eval: Vec<F> = (0..n_vars).map(|_| rng.random()).collect();
let scalar: EF = rng.random();

let mut expected = EF::zero_vec(1 << n_vars);
compute_eval_eq_base::<F, EF, true>(&eval, &mut expected, scalar);

let mut packed = <EF as ExtensionField<F>>::ExtensionPacking::zero_vec(1 << (n_vars - log_packing_width));
compute_eval_eq_base_packed::<F, EF, true>(&eval, &mut packed, scalar);

let unpacked: Vec<EF> = <EF as ExtensionField<F>>::ExtensionPacking::to_ext_iter_vec(packed);
assert_eq!(expected, unpacked, "n_vars = {n_vars}");
}
}

/// `par_eval_eq` hands the kernel a middle slice of any length >= 2, so the hardcoded arms
/// below `log_packing_width` must agree with the unpacked-output twin. Calling the kernel
/// directly keeps this independent of the SIMD width and thread count.
Expand Down
26 changes: 26 additions & 0 deletions crates/lean_multisig_api/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "lean_multisig_api"
version.workspace = true
edition.workspace = true

[lints]
workspace = true

[dependencies]
# `lean_vm` is here only for the compile-time assertion in `plan.rs` that the crate's
# `log_inv_rate` choices sit inside the band `lean_prover::default_whir_config` accepts.
lean_vm.workspace = true
xmss.workspace = true
rec_aggregation.workspace = true
backend.workspace = true
ssz.workspace = true
postcard.workspace = true
rand.workspace = true
sha2.workspace = true

[dev-dependencies]
# `round_trip.rs`'s `#[ignore]`d LEAF_TARGET tests need 1501 real signatures, which
# `xmss::signers_cache` has pre-generated and cached on disk. The feature is already on in any
# workspace build because `rec_aggregation` enables it, so this line changes nothing today — it
# states the dependency this crate's own tests have, rather than borrowing another crate's.
xmss = { workspace = true, features = ["test-utils"] }
114 changes: 114 additions & 0 deletions crates/lean_multisig_api/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
use std::fmt::{Display, Formatter};

/// Every way a `lean_multisig_api` operation can fail.
#[non_exhaustive]
#[derive(Debug)]
pub enum Error {
/// [`crate::setup`] must be called before operations involving recursive proofs.
NotInitialized,
KeyGen(xmss::XmssKeyGenError),
Sign(xmss::XmssSignatureError),
/// A raw signature did not verify. The index refers to the input of [`crate::aggregate`],
/// or is zero when verifying one standalone [`crate::Signature`].
InvalidSignature {
index: usize,
source: xmss::XmssVerifyError,
},
Aggregation(rec_aggregation::AggregationError),
Proof(backend::ProofError),
/// A serialized [`crate::Signature`] envelope was malformed or unsupported.
MalformedSignature,
/// A serialized [`crate::MultiClaimProof`] envelope was malformed or unsupported.
MalformedMultiClaimProof,
/// A caller-supplied public key was not canonically encoded.
MalformedPublicKey,
/// Secret-key bytes failed their format or integrity checks.
MalformedSecretKey,
TooManySigners {
got: usize,
max: usize,
},
TooManyClaims {
got: usize,
max: usize,
},
Empty,
MessageMismatch,
SignerSetMismatch,
ClaimSetMismatch,
}

impl From<xmss::XmssKeyGenError> for Error {
fn from(err: xmss::XmssKeyGenError) -> Self {
Self::KeyGen(err)
}
}

impl From<xmss::XmssSignatureError> for Error {
fn from(err: xmss::XmssSignatureError) -> Self {
Self::Sign(err)
}
}

impl From<rec_aggregation::AggregationError> for Error {
fn from(err: rec_aggregation::AggregationError) -> Self {
match err {
rec_aggregation::AggregationError::InvalidChildProof(err) => Self::Proof(err),
err => Self::Aggregation(err),
}
}
}

impl From<backend::ProofError> for Error {
fn from(err: backend::ProofError) -> Self {
Self::Proof(err)
}
}

impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotInitialized => write!(f, "Call lean_multisig_api::setup() before using recursive proofs"),
Self::KeyGen(_) => write!(f, "Key generation failed"),
Self::Sign(_) => write!(f, "XMSS signing operation failed"),
Self::InvalidSignature { index, .. } => write!(f, "Signature {index} is invalid"),
Self::Aggregation(_) => write!(f, "Aggregation failed"),
Self::Proof(_) => write!(f, "Proof error"),
Self::MalformedSignature => write!(f, "The supplied bytes are not a well-formed signature"),
Self::MalformedMultiClaimProof => {
write!(f, "The supplied bytes are not a well-formed multi-claim proof")
}
Self::MalformedPublicKey => write!(f, "A supplied public key is not canonically encoded"),
Self::MalformedSecretKey => write!(f, "Secret key bytes failed validation"),
Self::TooManySigners { got, max } => write!(f, "Too many signers: {got} (max {max})"),
Self::TooManyClaims { got, max } => write!(f, "Too many distinct claims: {got} (max {max})"),
Self::Empty => write!(f, "Nothing to aggregate: no signatures were supplied"),
Self::MessageMismatch => write!(f, "The signature proves a different claim than the one supplied"),
Self::SignerSetMismatch => write!(f, "The proved signer set differs from the expected one"),
Self::ClaimSetMismatch => write!(f, "The proved claims or signer sets differ from the expected ones"),
}
}
}

impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::NotInitialized => None,
Self::KeyGen(err) => Some(err),
Self::Sign(err) => Some(err),
Self::InvalidSignature { source, .. } => Some(source),
Self::Aggregation(err) => Some(err),
Self::Proof(err) => Some(err),
Self::MalformedSignature
| Self::MalformedMultiClaimProof
| Self::MalformedPublicKey
| Self::MalformedSecretKey
| Self::TooManySigners { .. }
| Self::TooManyClaims { .. }
| Self::Empty
| Self::MessageMismatch
| Self::SignerSetMismatch
| Self::ClaimSetMismatch => None,
}
}
}
Loading
Loading