Skip to content

chore(do not merge): aggregate API - #267

Draft
kevaundray wants to merge 13 commits into
leanEthereum:mainfrom
kevaundray:feat/lean-sig
Draft

chore(do not merge): aggregate API#267
kevaundray wants to merge 13 commits into
leanEthereum:mainfrom
kevaundray:feat/lean-sig

Conversation

@kevaundray

@kevaundray kevaundray commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Quick prototype of the single aggregation API

`base_eval_eq_packed_with_packed_output` asserted
`log_packing_width <= eval_points.len()`, but `eval_points` is the
*middle* slice produced 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 the packed scalar. Its length is therefore
`n - log_packing_width - log_chunks` and has nothing to do with the
packing width. The callers only guarantee it is at least 2, while the
assert demanded at least `log_packing_width`, so every `n` in
`[lpw + log_chunks + 2, 2*lpw + log_chunks)` panicked in debug builds.

On a 32-thread AVX512 host that is log_chunks=7, lpw=4, so 13- and
14-variable polynomials aborted `test_packed_eval_eq` and
`lean_prover`'s `test_small_memory`. The band is machine-dependent and
non-empty on any target with a packing width above 4.

The invariant is real, but it belongs to the callers, which already
check it against the full point (`compute_eval_eq_base_packed` and
`compute_eval_eq_base_packed_batched`).

This restores 5cf504a, which removed the same assert for the same
reason and was reverted by e45a0ed. It regressed because no test
covered the band, so add one that computes the bounds from the runtime
thread count and SIMD width rather than hardcoding them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kevaundray kevaundray changed the title chore: do not merge chore(do not merge): aggregate API Aug 14, 2026
Adds `crates/lean_multisig_api`, a facade over `xmss` and
`rec_aggregation` exposing signing and single-message aggregation over
plain byte slices. Every tuning parameter -- recursion topology and each
node's `log_inv_rate` -- is chosen internally, so callers have no knobs.
Callers needing the full parameter space keep using `rec_aggregation`.

    aggregate(proof_or_sig, public_keys, message, slot) -> Vec<u8>
    verify(aggregate, message, slot)                    -> Vec<Vec<u8>>
    verify_with_signers(aggregate, expected, message, slot)
    SecretKey::{generate, from_seed, sign, public_key, prepare, ...}

`proof_or_sig` mixes raw XMSS signatures with prior aggregates,
discriminated by length (a signature is exactly SIGNATURE_SSZ_LEN).
Aggregates carry their own signer sets, so `public_keys` covers raw
entries only and the two vectors are deliberately not index-aligned --
the sharpest edge in the API, documented accordingly.

`verify` returns the proved signer set rather than a bool: an aggregate
over the wrong validator set is still a valid proof, so ignoring who
signed should require discarding a value rather than simply not asking.

Five modules: `plan.rs` is a pure tree planner (millisecond tests, no
prover), `codec.rs` does length dispatch and pubkey pairing, `key.rs`
holds `SecretKey` as an opaque handle so its bottom-subtree cache
survives across signatures, `lib.rs` the entry points, `error.rs` one
flattened enum. 62 tests: 44 unit, 18 across five integration binaries,
plus two `#[ignore]`d boundary tests wired into CI.

Three upstream traps are absorbed so callers never meet them: the
bytecode `OnceLock` (which panics via `get_aggregation_bytecode` and
silently returns `None` from `from_bytes`), the `MAX_XMSS_AGGREGATED`
ceiling applying at every node so recursion cannot raise it, and the
requirement to serialize proving jobs.

`LEAF_TARGET = 1500` is measured rather than inherited: a full leaf
proves in ~8s at the slowest rate the planner assigns, and 1501 splits
and proves in ~6.7s. The largest leaf that proves is still unknown, so
the value is known-good rather than known-optimal.

Design and rationale in docs/plans/2026-08-14-lean-sig-facade-design.md;
the task breakdown and deferred tuning questions in
docs/plans/2026-08-14-lean-sig-implementation.md. The crate was named
`lean_sig` while it was built, which is why those filenames and the
pre-squash commit scopes say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
}
}

/// `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

kevaundray and others added 2 commits August 14, 2026 19:00
Quality-only changes from a four-angle review (reuse, simplification,
efficiency, altitude). No behavior changes except the two noted below,
both of which only remove work.

- Drop `Error::Verify`. Nothing in the crate could construct it: the
  facade never verifies a raw XMSS signature outside the prover, so that
  fault surfaces as `Error::Aggregation`. A public enum variant for an
  unreachable case costs consumers a match arm and weakened the
  deliberately-exhaustive `source()` match by seeding it with a variant
  nobody decided about.

- Share `codec::Raw` instead of restating the same tuple alias in
  `lib.rs`. The two only ever typechecked against each other because
  they happened to be written identically.

- Extract `proves()` for the "does this aggregate prove (message, slot)?"
  test, which `aggregate` and `verify` spelled two different ways. Also
  the only place reaching two levels into `rec_aggregation`'s struct.

- `verify_with_signers` no longer collects the proved set into a second
  `BTreeSet`. `verify` returns a set `check_single_message_pubkeys`
  already enforced to be strictly sorted, so a length match plus
  containment is exact. Saves building a tree of up to 32768 nodes on
  the path a node runs per gossiped aggregate.

- Guard the three `log_inv_rate` literals against
  `lean_vm::{MIN,MAX}_WHIR_LOG_INV_RATE`. `default_whir_config` asserts
  rather than errors, so a narrowed band upstream would abort every
  aggregation at proving time with no compile- or test-time signal.
  Same treatment `MAX_FAN_IN` and `LOG_LIFETIME` already get, for the
  same reason: this crate does not own the value. `lean_vm` moves from
  dev- to normal dependency for it, which also retires a dev-dep entry
  that was redundant (integration tests see `[dependencies]` too).

- `warm_up`'s doc said what it warms but not what it does not. The
  worker pool and DFT twiddle table are also lazy and it touches
  neither, so a service following the doc still paid both inside its
  first request. Points at `setup_prover_without_arena`.

- `base_set()` in round_trip.rs, replacing four hand-rolled collects.

TODO.md records that `#[ignore]` carries two meanings, which is why CI
selects slow tests by naming a binary rather than `--include-ignored`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`docs/` did not exist before this work and held only the two planning
artifacts. Removing them, but four code comments referenced them and the
substance was worth keeping, so it moves into the code that depends on it
rather than disappearing:

- `LEAF_TARGET`'s doc now records the measurement itself: the two
  `#[ignore]`d boundary tests prove a full 1500-signature leaf and the
  1501 split, so it is a size the prover demonstrably accepts. Also
  records what is still unmeasured — the *largest* leaf that proves —
  so the value reads as known-good rather than known-optimal.
- `Plan::Node.raw` states what blocks the mixed raw+child node: roughly
  2x on the incremental fold path, blocked only on whether that trace
  size fits, which nobody has measured.
- The greedy-split comment gains the data point that argues against
  balancing: 1501 as [1500, 1] plus a root measures faster than 1500 as
  one node, because only the root pays `RATE_ROOT`.
- Two "Task N" references dropped; the task numbering they pointed at
  no longer exists anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +70 to +76
static PROVE_LOCK: Mutex<()> = Mutex::new(());

/// The shared 2-signer aggregate over `(MSG, SLOT)`, proved at most once per process.
///
/// Tests are independent in what they *assert* — this is a fixture, not a channel between them —
/// but sharing it means one proving job instead of six.
static BASE: OnceLock<Vec<u8>> = OnceLock::new();

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.

should remove

@kevaundray

Copy link
Copy Markdown
Contributor Author

Created this initial PR to incorporate things: ethereum/lean-multisig-bindings#10

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant