From 36c388ee8b321e01183ca4b83fec41cea529ee60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:02:05 -0300 Subject: [PATCH 01/20] feat(crypto)!: track leanVM main (internalized XMSS + reworked aggregation) Point the leanVM dependency at `main`. leanVM's `main` ("Xmss api rework") removed the `lean-multisig` and `leansig_wrapper` crates ethlambda imported, internalized XMSS in its own `xmss` crate (dropping the external leanSig dependency), and reworked the recursive-aggregation API. Tracking main is therefore a migration of the whole signature stack, not a version bump. Changes: - Cargo: replace `leansig`/`lean-multisig`/`leansig_wrapper` with `xmss` + `rec_aggregation` (git, branch=main), plus `ssz` (ethereum_ssz, used by the xmss SSZ impls) and `postcard` (secret-key format). - types::signature: rebuild ValidatorPublicKey/Signature/SecretKey on leanVM's `xmss` crate. Keys/signatures (de)serialize via SSZ (`PUB_KEY_SSZ_LEN` / `SIGNATURE_SSZ_LEN`); secret keys via postcard. `SIGNATURE_SIZE` and the new `PUBLIC_KEY_SIZE` are sourced from the xmss scheme constants. The sliding preparation-window API becomes a fixed activation range (`advance_preparation` is now a no-op; keys warm their signing cache on demand). - crypto: rewrite aggregation/verification against `rec_aggregation`. Proofs now serialize with `to_bytes()`/`from_bytes()` and embed participant pubkeys, so the verify paths cross-check the embedded set against the expected validators instead of attaching them at decode time. `setup_prover`/`setup_verifier` are replaced by the idempotent `init_aggregation_bytecode`. - Validator public keys are now 32 bytes (was 52); genesis/state layouts and fixtures updated accordingly. BREAKING: this changes the on-wire signature/proof formats and the genesis key format. It is interop-incompatible with clients still on the previous scheme and requires regenerated genesis keys; the fixture-driven spec tests need regenerated fixtures. Landing this needs ecosystem coordination. --- Cargo.lock | 680 +++++------------- Cargo.toml | 13 +- bin/ethlambda/src/checkpoint_sync.rs | 12 +- bin/ethlambda/src/main.rs | 11 +- crates/blockchain/Cargo.toml | 1 - crates/blockchain/src/aggregation.rs | 29 +- crates/blockchain/src/block_builder.rs | 20 +- crates/blockchain/src/reaggregate.rs | 7 +- crates/blockchain/state_transition/src/lib.rs | 4 +- crates/common/crypto/Cargo.toml | 10 +- crates/common/crypto/src/lib.rs | 199 ++--- crates/common/test-fixtures/src/common.rs | 7 +- crates/common/types/Cargo.toml | 4 +- crates/common/types/src/attestation.rs | 60 +- crates/common/types/src/block.rs | 32 +- crates/common/types/src/genesis.rs | 34 +- crates/common/types/src/signature.rs | 198 ++--- crates/common/types/src/state.rs | 4 +- crates/net/rpc/src/genesis.rs | 4 +- crates/storage/Cargo.toml | 1 - crates/storage/src/state_diff.rs | 8 +- crates/storage/src/store.rs | 23 +- 22 files changed, 524 insertions(+), 837 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ddd13c0c..f9063761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "air" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +dependencies = [ + "field", + "poly", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -652,19 +661,18 @@ dependencies = [ [[package]] name = "backend" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "mt-air", - "mt-fiat-shamir", - "mt-field", - "mt-koala-bear", - "mt-poly", - "mt-sumcheck", - "mt-symetric", - "mt-utils", - "mt-whir", +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +dependencies = [ + "air", + "fiat-shamir", + "field", + "koala-bear", "parallel", - "tracing", + "poly", + "sumcheck", + "symetric", + "utils", + "whir", "zk-alloc", ] @@ -1166,12 +1174,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "const-str" version = "0.4.3" @@ -1601,7 +1603,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid 0.9.6", + "const-oid", "pem-rfc7468", "zeroize", ] @@ -1732,7 +1734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid 0.9.6", + "const-oid", "crypto-common 0.1.7", "subtle", ] @@ -1744,7 +1746,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -2048,7 +2049,6 @@ dependencies = [ "ethlambda-test-fixtures", "ethlambda-types", "hex", - "leansig", "libssz", "libssz-types", "rand 0.10.1", @@ -2065,13 +2065,13 @@ dependencies = [ name = "ethlambda-crypto" version = "0.1.0" dependencies = [ + "ethereum_ssz", "ethlambda-types", "hex", - "lean-multisig", - "leansig", - "leansig_wrapper", "rand 0.10.1", + "rec_aggregation", "thiserror 2.0.18", + "xmss", ] [[package]] @@ -2170,7 +2170,6 @@ name = "ethlambda-storage" version = "0.1.0" dependencies = [ "ethlambda-types", - "leansig", "libssz", "libssz-derive", "libssz-types", @@ -2199,18 +2198,20 @@ name = "ethlambda-types" version = "0.1.0" dependencies = [ "datatest-stable 0.3.3", + "ethereum_ssz", "ethlambda-test-fixtures", "hex", - "leansig", "libssz", "libssz-derive", "libssz-merkle", "libssz-types", + "postcard", "rand 0.10.1", "serde", "serde_json", "serde_yaml_ng", "thiserror 2.0.18", + "xmss", ] [[package]] @@ -2560,6 +2561,31 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-shamir" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +dependencies = [ + "field", + "koala-bear", + "parallel", + "serde", + "symetric", + "utils", +] + +[[package]] +name = "field" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +dependencies = [ + "parallel", + "paste", + "rand 0.10.1", + "serde", + "utils", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2962,7 +2988,7 @@ dependencies = [ "hash32", "rustc_version 0.4.1", "serde", - "spin 0.9.8", + "spin", "stable_deref_trait", ] @@ -3618,6 +3644,18 @@ dependencies = [ "sha3-asm", ] +[[package]] +name = "koala-bear" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +dependencies = [ + "field", + "paste", + "rand 0.10.1", + "serde", + "utils", +] + [[package]] name = "konst" version = "0.2.20" @@ -3644,7 +3682,7 @@ dependencies = [ "serde_arrays", "sha2", "sp1_bls12_381", - "spin 0.9.8", + "spin", ] [[package]] @@ -3668,126 +3706,43 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lean-multisig" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "backend", - "clap", - "lean_vm", - "leansig_wrapper", - "libc", - "rand 0.10.1", - "rec_aggregation", - "serde_json", - "sub_protocols", - "system-info", - "utils", - "zk-alloc", -] - [[package]] name = "lean_compiler" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", "include_dir", "lean_vm", "pest", "pest_derive", - "rand 0.10.1", "sub_protocols", - "tracing", - "utils", + "xmss", ] [[package]] name = "lean_prover" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", - "itertools 0.14.0", "lean_compiler", "lean_vm", - "pest", - "pest_derive", "rand 0.10.1", "serde", "sub_protocols", "tracing", - "utils", + "xmss", ] [[package]] name = "lean_vm" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", - "itertools 0.14.0", - "leansig_wrapper", - "pest", - "pest_derive", - "rand 0.10.1", - "serde", "tracing", - "utils", -] - -[[package]] -name = "leansig" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanSig?branch=devnet4#15cbdd43ec8525aa43fea2f42cafc5ed366084ae" -dependencies = [ - "dashmap", - "ethereum_ssz", - "num-bigint 0.4.6", - "num-traits", - "p3-baby-bear", - "p3-field 0.5.1", - "p3-koala-bear 0.5.1", - "p3-symmetric 0.5.1", - "rand 0.10.1", - "rayon", - "serde", - "sha3 0.10.9", - "thiserror 2.0.18", -] - -[[package]] -name = "leansig_fast_keygen" -version = "0.1.0" -source = "git+https://github.com/TomWambsgans/leanSig?branch=devnet4-fast-keygen#0fa9e19b8946ef50a34f3d50d82918b98bcfa4a5" -dependencies = [ - "dashmap", - "ethereum_ssz", - "num-bigint 0.4.6", - "num-traits", - "p3-baby-bear", - "p3-field 0.5.1", - "p3-koala-bear 0.5.1", - "p3-symmetric 0.5.1", - "rand 0.10.1", - "rayon", - "serde", - "sha3 0.10.9", - "thiserror 2.0.18", -] - -[[package]] -name = "leansig_wrapper" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "backend", - "ethereum_ssz", - "leansig", - "leansig_fast_keygen", - "p3-field 0.5.1", - "rand 0.10.1", + "xmss", ] [[package]] @@ -4637,15 +4592,6 @@ dependencies = [ "libc", ] -[[package]] -name = "lz4_flex" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" -dependencies = [ - "twox-hash", -] - [[package]] name = "malachite" version = "0.6.1" @@ -4812,127 +4758,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "mt-air" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "mt-field", - "mt-poly", -] - -[[package]] -name = "mt-fiat-shamir" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "mt-field", - "mt-koala-bear", - "mt-symetric", - "mt-utils", - "parallel", - "serde", - "tracing", -] - -[[package]] -name = "mt-field" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "itertools 0.14.0", - "mt-utils", - "num-bigint 0.3.3", - "parallel", - "paste", - "rand 0.10.1", - "serde", - "tracing", -] - -[[package]] -name = "mt-koala-bear" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "itertools 0.14.0", - "mt-field", - "mt-utils", - "num-bigint 0.3.3", - "paste", - "rand 0.10.1", - "serde", - "tracing", -] - -[[package]] -name = "mt-poly" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "itertools 0.14.0", - "mt-field", - "mt-utils", - "parallel", - "rand 0.10.1", - "serde", - "system-info", - "zk-alloc", -] - -[[package]] -name = "mt-sumcheck" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "mt-air", - "mt-fiat-shamir", - "mt-field", - "mt-poly", - "parallel", - "tracing", - "zk-alloc", -] - -[[package]] -name = "mt-symetric" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "mt-field", - "mt-koala-bear", - "parallel", - "zk-alloc", -] - -[[package]] -name = "mt-utils" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "serde", -] - -[[package]] -name = "mt-whir" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" -dependencies = [ - "itertools 0.14.0", - "mt-fiat-shamir", - "mt-field", - "mt-koala-bear", - "mt-poly", - "mt-sumcheck", - "mt-symetric", - "mt-utils", - "parallel", - "rand 0.10.1", - "system-info", - "tracing", - "zk-alloc", -] - [[package]] name = "multiaddr" version = "0.18.2" @@ -5310,21 +5135,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "p3-baby-bear" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "p3-challenger 0.5.1", - "p3-field 0.5.1", - "p3-mds 0.5.1", - "p3-monty-31", - "p3-poseidon1", - "p3-poseidon2 0.5.1", - "p3-symmetric 0.5.1", - "rand 0.10.1", -] - [[package]] name = "p3-bn254-fr" version = "0.3.3-succinct" @@ -5333,9 +5143,9 @@ checksum = "577200e3fa7e49e2b21e940a6dc7399dc63acb8581da088558cdf7c455adafc0" dependencies = [ "ff", "num-bigint 0.4.6", - "p3-field 0.3.3-succinct", - "p3-poseidon2 0.3.3-succinct", - "p3-symmetric 0.3.3-succinct", + "p3-field", + "p3-poseidon2", + "p3-symmetric", "rand 0.8.6", "serde", ] @@ -5346,51 +5156,24 @@ version = "0.3.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75358edd6e2562752c01f5064a66d88144a3e75ace0407166dbdf8a727597f52" dependencies = [ - "p3-field 0.3.3-succinct", - "p3-maybe-rayon 0.3.3-succinct", - "p3-symmetric 0.3.3-succinct", - "p3-util 0.3.3-succinct", + "p3-field", + "p3-maybe-rayon", + "p3-symmetric", + "p3-util", "serde", "tracing", ] -[[package]] -name = "p3-challenger" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "p3-field 0.5.1", - "p3-maybe-rayon 0.5.1", - "p3-monty-31", - "p3-symmetric 0.5.1", - "p3-util 0.5.1", - "tracing", -] - [[package]] name = "p3-dft" version = "0.3.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "761f1e1b014f2b1b69bd0309124e233d64aa3590e6a41ee786000dd849506d51" dependencies = [ - "p3-field 0.3.3-succinct", - "p3-matrix 0.3.3-succinct", - "p3-maybe-rayon 0.3.3-succinct", - "p3-util 0.3.3-succinct", - "tracing", -] - -[[package]] -name = "p3-dft" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "itertools 0.14.0", - "p3-field 0.5.1", - "p3-matrix 0.5.1", - "p3-maybe-rayon 0.5.1", - "p3-util 0.5.1", - "spin 0.10.0", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-util", "tracing", ] @@ -5403,26 +5186,11 @@ dependencies = [ "itertools 0.12.1", "num-bigint 0.4.6", "num-traits", - "p3-util 0.3.3-succinct", + "p3-util", "rand 0.8.6", "serde", ] -[[package]] -name = "p3-field" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "itertools 0.14.0", - "num-bigint 0.4.6", - "p3-maybe-rayon 0.5.1", - "p3-util 0.5.1", - "paste", - "rand 0.10.1", - "serde", - "tracing", -] - [[package]] name = "p3-koala-bear" version = "0.3.3-succinct" @@ -5431,30 +5199,15 @@ checksum = "6cea0ba3389b034b6088d566aea8b57aa29dd2e180966e0c8056f61331c92b4e" dependencies = [ "cfg-if 1.0.4", "num-bigint 0.4.6", - "p3-field 0.3.3-succinct", - "p3-mds 0.3.3-succinct", - "p3-poseidon2 0.3.3-succinct", - "p3-symmetric 0.3.3-succinct", + "p3-field", + "p3-mds", + "p3-poseidon2", + "p3-symmetric", "rand 0.8.6", "rustc_version 0.4.1", "serde", ] -[[package]] -name = "p3-koala-bear" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "p3-challenger 0.5.1", - "p3-field 0.5.1", - "p3-mds 0.5.1", - "p3-monty-31", - "p3-poseidon1", - "p3-poseidon2 0.5.1", - "p3-symmetric 0.5.1", - "rand 0.10.1", -] - [[package]] name = "p3-matrix" version = "0.3.3-succinct" @@ -5462,39 +5215,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fae5cc6ce726cc265cc687c1214e3f1ac1f5c6e973442286ba00d1e75da1c3cb" dependencies = [ "itertools 0.12.1", - "p3-field 0.3.3-succinct", - "p3-maybe-rayon 0.3.3-succinct", - "p3-util 0.3.3-succinct", + "p3-field", + "p3-maybe-rayon", + "p3-util", "rand 0.8.6", "serde", "tracing", ] -[[package]] -name = "p3-matrix" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "itertools 0.14.0", - "p3-field 0.5.1", - "p3-maybe-rayon 0.5.1", - "p3-util 0.5.1", - "rand 0.10.1", - "serde", - "tracing", -] - [[package]] name = "p3-maybe-rayon" version = "0.3.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55ac1d2f102cf8c71dba1b449575c99697781fcc028831e83d2245787bd7a650" -[[package]] -name = "p3-maybe-rayon" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" - [[package]] name = "p3-mds" version = "0.3.3-succinct" @@ -5502,59 +5236,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f072643e385d65fb9eb089ee6824b320417f78671a0db748566e057e28b250e" dependencies = [ "itertools 0.12.1", - "p3-dft 0.3.3-succinct", - "p3-field 0.3.3-succinct", - "p3-matrix 0.3.3-succinct", - "p3-symmetric 0.3.3-succinct", - "p3-util 0.3.3-succinct", + "p3-dft", + "p3-field", + "p3-matrix", + "p3-symmetric", + "p3-util", "rand 0.8.6", ] -[[package]] -name = "p3-mds" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "p3-dft 0.5.1", - "p3-field 0.5.1", - "p3-symmetric 0.5.1", - "p3-util 0.5.1", - "rand 0.10.1", -] - -[[package]] -name = "p3-monty-31" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "itertools 0.14.0", - "num-bigint 0.4.6", - "p3-dft 0.5.1", - "p3-field 0.5.1", - "p3-matrix 0.5.1", - "p3-maybe-rayon 0.5.1", - "p3-mds 0.5.1", - "p3-poseidon1", - "p3-poseidon2 0.5.1", - "p3-symmetric 0.5.1", - "p3-util 0.5.1", - "paste", - "rand 0.10.1", - "serde", - "spin 0.10.0", - "tracing", -] - -[[package]] -name = "p3-poseidon1" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "p3-field 0.5.1", - "p3-symmetric 0.5.1", - "rand 0.10.1", -] - [[package]] name = "p3-poseidon2" version = "0.3.3-succinct" @@ -5562,25 +5251,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00cc4b6e8a439f79541b0910a016da9e6e12a05a24309bbb713e1db0db396952" dependencies = [ "gcd", - "p3-field 0.3.3-succinct", - "p3-mds 0.3.3-succinct", - "p3-symmetric 0.3.3-succinct", + "p3-field", + "p3-mds", + "p3-symmetric", "rand 0.8.6", "serde", ] -[[package]] -name = "p3-poseidon2" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "p3-field 0.5.1", - "p3-mds 0.5.1", - "p3-symmetric 0.5.1", - "p3-util 0.5.1", - "rand 0.10.1", -] - [[package]] name = "p3-symmetric" version = "0.3.3-succinct" @@ -5588,18 +5265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eebff7fea7deb08a57ccf731a0ed39df25cc66a0e0c2d92c4472c4dee02ee21" dependencies = [ "itertools 0.12.1", - "p3-field 0.3.3-succinct", - "serde", -] - -[[package]] -name = "p3-symmetric" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "itertools 0.14.0", - "p3-field 0.5.1", - "p3-util 0.5.1", + "p3-field", "serde", ] @@ -5612,15 +5278,6 @@ dependencies = [ "serde", ] -[[package]] -name = "p3-util" -version = "0.5.1" -source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -dependencies = [ - "serde", - "transpose", -] - [[package]] name = "pairing" version = "0.23.0" @@ -5633,7 +5290,7 @@ dependencies = [ [[package]] name = "parallel" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "system-info", ] @@ -5825,6 +5482,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +dependencies = [ + "field", + "parallel", + "rand 0.10.1", + "serde", + "system-info", + "utils", + "zk-alloc", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -6397,25 +6068,20 @@ dependencies = [ [[package]] name = "rec_aggregation" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", "include_dir", "lean_compiler", "lean_prover", "lean_vm", - "leansig_wrapper", - "lz4_flex", "objc2", "objc2-foundation", "postcard", - "rand 0.10.1", "serde", - "sha3 0.11.0", "sub_protocols", "tracing", - "utils", - "zk-alloc", + "xmss", ] [[package]] @@ -7110,7 +6776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a473c3a06b466dd0708829415a8a9fab451740da066e07862c8c098904aaad6" dependencies = [ "itertools 0.14.0", - "p3-field 0.3.3-succinct", + "p3-field", "serde", ] @@ -7136,7 +6802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4e80df718cef7d3100658dc8b46fafcc994b814421ec9a7d0763a6ee1e5070c" dependencies = [ "futures", - "p3-challenger 0.3.3-succinct", + "p3-challenger", "serde", "slop-algebra", "slop-symmetric", @@ -7149,7 +6815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6586b1c0e66c503e4026a8cb007349fa99c2466957c5b09d18fe658d1391ed8" dependencies = [ "lazy_static", - "p3-koala-bear 0.3.3-succinct", + "p3-koala-bear", "serde", "slop-algebra", "slop-challenger", @@ -7163,7 +6829,7 @@ version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c956b11fff1b8a071fa4ba982dc35e458cff1620dc7b33d9cf22d8df30895f79" dependencies = [ - "p3-poseidon2 0.3.3-succinct", + "p3-poseidon2", ] [[package]] @@ -7181,7 +6847,7 @@ version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "955145ad6e3a1d083a428f9274071cfbb44c3b29013aae9d6c4c29fb7328cfc0" dependencies = [ - "p3-symmetric 0.3.3-succinct", + "p3-symmetric", ] [[package]] @@ -7373,15 +7039,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spin" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" -dependencies = [ - "lock_api", -] - [[package]] name = "spki" version = "0.7.3" @@ -7410,12 +7067,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f446288b699d66d0fd2e30d1cfe7869194312524b3b9252594868ed26ef056a" -[[package]] -name = "strength_reduce" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" - [[package]] name = "strsim" version = "0.11.1" @@ -7446,12 +7097,11 @@ dependencies = [ [[package]] name = "sub_protocols" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", "lean_vm", "tracing", - "utils", ] [[package]] @@ -7460,6 +7110,31 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sumcheck" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +dependencies = [ + "air", + "fiat-shamir", + "field", + "parallel", + "poly", + "tracing", + "zk-alloc", +] + +[[package]] +name = "symetric" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +dependencies = [ + "field", + "koala-bear", + "parallel", + "zk-alloc", +] + [[package]] name = "syn" version = "1.0.109" @@ -7540,7 +7215,7 @@ dependencies = [ [[package]] name = "system-info" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "libc", ] @@ -7951,28 +7626,12 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "transpose" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" -dependencies = [ - "num-integer", - "strength_reduce", -] - [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "twox-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" - [[package]] name = "typenum" version = "1.20.0" @@ -8089,10 +7748,10 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utils" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" dependencies = [ - "backend", - "tracing", + "parallel", + "serde", "tracing-forest", "tracing-subscriber", ] @@ -8350,6 +8009,25 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whir" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +dependencies = [ + "fiat-shamir", + "field", + "koala-bear", + "parallel", + "poly", + "rand 0.10.1", + "sumcheck", + "symetric", + "system-info", + "tracing", + "utils", + "zk-alloc", +] + [[package]] name = "wide" version = "0.7.33" @@ -8889,6 +8567,18 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "xmss" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +dependencies = [ + "backend", + "ethereum_ssz", + "postcard", + "rand 0.10.1", + "serde", +] + [[package]] name = "xxhash-rust" version = "0.8.15" @@ -9055,7 +8745,7 @@ dependencies = [ [[package]] name = "zk-alloc" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30fdddbbf8ae26a333116c68cec7026" +source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "libc", "parallel", diff --git a/Cargo.toml b/Cargo.toml index 100b1a4a..28fc5b82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,8 +55,17 @@ prometheus = "0.14" clap = { version = "4.3", features = ["derive", "env"] } -# XMSS signatures -leansig = { git = "https://github.com/leanEthereum/leanSig", branch = "devnet4" } +# XMSS signatures + recursive aggregation (leanVM, main branch). +# leanVM's `main` internalized XMSS (dropping the external leanSig dependency) +# and reworked the aggregation API (see "Xmss api rework"). +xmss = { git = "https://github.com/leanEthereum/leanVM.git", branch = "main" } +rec_aggregation = { git = "https://github.com/leanEthereum/leanVM.git", branch = "main" } + +# SSZ codec used by leanVM's xmss pubkey/signature types (ethereum_ssz, not libssz). +ssz = { package = "ethereum_ssz", version = "0.10" } + +# Secret-key (de)serialization for the leanVM xmss key format. +postcard = { version = "1.1.3", features = ["alloc"] } # SSZ implementation libssz = "0.2.2" diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index 9bed6372..e84aeb44 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -385,16 +385,16 @@ mod tests { fn create_test_validator() -> Validator { Validator { - attestation_pubkey: [1u8; 52], - proposal_pubkey: [11u8; 52], + attestation_pubkey: [1u8; 32], + proposal_pubkey: [11u8; 32], index: 0, } } fn create_different_validator() -> Validator { Validator { - attestation_pubkey: [2u8; 52], - proposal_pubkey: [22u8; 52], + attestation_pubkey: [2u8; 32], + proposal_pubkey: [22u8; 32], index: 0, } } @@ -402,8 +402,8 @@ mod tests { fn create_validators_with_indices(count: usize) -> Vec { (0..count) .map(|i| Validator { - attestation_pubkey: [i as u8 + 1; 52], - proposal_pubkey: [i as u8 + 101; 52], + attestation_pubkey: [i as u8 + 1; 32], + proposal_pubkey: [i as u8 + 101; 32], index: i as u64, }) .collect() diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index b4d2828d..e0392eb5 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -479,8 +479,8 @@ fn read_bootnodes(bootnodes_path: impl AsRef) -> eyre::Result Vec { (0..n) .map(|i| Validator { - attestation_pubkey: [i as u8; 52], - proposal_pubkey: [i as u8; 52], + attestation_pubkey: [i as u8; 32], + proposal_pubkey: [i as u8; 32], index: i as u64, }) .collect() } - /// A cheap-but-real XMSS signature (tiny lifetime, cached) for tests that - /// only need `ValidatorSignature::from_bytes` to succeed. `resolve_job` - /// never checks signature validity, only that it clones and carries a - /// resolvable id — mirrors `ethlambda_storage::store::tests::make_dummy_sig`. + /// A structurally valid XMSS signature for tests that only need + /// `ValidatorSignature::from_bytes` to succeed. `resolve_job` never checks + /// signature validity, only that it clones and carries a resolvable id — + /// mirrors `ethlambda_storage::store::tests::make_dummy_sig`. An all-zero + /// blob decodes as a valid (unverifiable) signature. fn dummy_sig() -> ValidatorSignature { - use ethlambda_types::signature::LeanSignatureScheme; - use leansig::{serialization::Serializable, signature::SignatureScheme}; - use rand::{SeedableRng, rngs::StdRng}; - - static CACHED_SIG: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - let mut rng = StdRng::seed_from_u64(42); - let lifetime = 1 << 5; // small for speed - let (_pk, sk) = LeanSignatureScheme::key_gen(&mut rng, 0, lifetime); - let sig = LeanSignatureScheme::sign(&sk, 0, &[0u8; 32]).unwrap(); - sig.to_bytes() - }); - - ValidatorSignature::from_bytes(&CACHED_SIG).expect("cached test signature") + use ethlambda_types::signature::SIGNATURE_SIZE; + ValidatorSignature::from_bytes(&vec![0u8; SIGNATURE_SIZE]) + .expect("all-zero test signature decodes") } /// A `HashedAttestationData` over default (all-zero) data for `resolve_job` diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 787c55b4..91935f08 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -973,8 +973,8 @@ mod tests { let validators: Vec<_> = (0..NUM_VALIDATORS) .map(|i| ethlambda_types::state::Validator { - attestation_pubkey: [i as u8; 52], - proposal_pubkey: [i as u8; 52], + attestation_pubkey: [i as u8; 32], + proposal_pubkey: [i as u8; 32], index: i as u64, }) .collect(); @@ -1133,8 +1133,8 @@ mod tests { let validators: Vec<_> = (0..NUM_VALIDATORS) .map(|i| ethlambda_types::state::Validator { - attestation_pubkey: [i as u8; 52], - proposal_pubkey: [i as u8; 52], + attestation_pubkey: [i as u8; 32], + proposal_pubkey: [i as u8; 32], index: i as u64, }) .collect(); @@ -1262,8 +1262,8 @@ mod tests { let validators: Vec<_> = (0..NUM_VALIDATORS) .map(|i| ethlambda_types::state::Validator { - attestation_pubkey: [i as u8; 52], - proposal_pubkey: [i as u8; 52], + attestation_pubkey: [i as u8; 32], + proposal_pubkey: [i as u8; 32], index: i as u64, }) .collect(); @@ -1566,8 +1566,8 @@ mod tests { let validators: Vec<_> = (0..NUM_VALIDATORS) .map(|i| ethlambda_types::state::Validator { - attestation_pubkey: [i as u8; 52], - proposal_pubkey: [i as u8; 52], + attestation_pubkey: [i as u8; 32], + proposal_pubkey: [i as u8; 32], index: i as u64, }) .collect(); @@ -1693,8 +1693,8 @@ mod tests { let validators: Vec<_> = (0..NUM_VALIDATORS) .map(|i| ethlambda_types::state::Validator { - attestation_pubkey: [i as u8; 52], - proposal_pubkey: [i as u8; 52], + attestation_pubkey: [i as u8; 32], + proposal_pubkey: [i as u8; 32], index: i as u64, }) .collect(); diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs index f47d6c51..92ff3108 100644 --- a/crates/blockchain/src/reaggregate.rs +++ b/crates/blockchain/src/reaggregate.rs @@ -122,11 +122,8 @@ pub fn reaggregate_from_block( // Step 1: SNARK-split this attestation's component out of the block's // merged multi-message aggregate proof. let merged_bytes = signed_block.proof.proof_bytes(); - let split_bytes = match ethlambda_crypto::split_type_2_by_message( - merged_bytes, - pubkeys_per_component.clone(), - &data_root, - ) { + let split_bytes = match ethlambda_crypto::split_type_2_by_message(merged_bytes, &data_root) + { Ok(bytes) => bytes, Err(err) => { debug!(%err, data_root = %ethlambda_types::ShortRoot(&data_root.0), diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index 403b170a..69448a7c 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -623,8 +623,8 @@ mod tests { fn make_validators(n: usize) -> Vec { (0..n) .map(|i| Validator { - attestation_pubkey: [i as u8; 52], - proposal_pubkey: [i as u8; 52], + attestation_pubkey: [i as u8; 32], + proposal_pubkey: [i as u8; 32], index: i as u64, }) .collect() diff --git a/crates/common/crypto/Cargo.toml b/crates/common/crypto/Cargo.toml index 5ec86eca..83291605 100644 --- a/crates/common/crypto/Cargo.toml +++ b/crates/common/crypto/Cargo.toml @@ -12,12 +12,11 @@ version.workspace = true [dependencies] ethlambda-types.workspace = true +# rec_aggregation is leanVM's recursive XMSS aggregation prover/verifier; +# xmss provides the public-key/signature types its API consumes. +rec_aggregation.workspace = true +xmss.workspace = true -lean-multisig = { git = "https://github.com/leanEthereum/leanVM.git", rev = "e2592df" } -# leansig_wrapper provides XmssPublicKey/XmssSignature types used by lean-multisig's public API -leansig_wrapper = { git = "https://github.com/leanEthereum/leanVM.git", rev = "e2592df" } - -leansig.workspace = true thiserror.workspace = true rand.workspace = true @@ -26,3 +25,4 @@ shadow-integration = [] [dev-dependencies] hex.workspace = true +ssz.workspace = true diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 4e081445..ca1dfa61 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -1,37 +1,37 @@ -use std::sync::Once; - use ethlambda_types::{ block::ByteList512KiB, primitives::H256, - signature::{ValidatorPublicKey, ValidatorSignature}, + signature::{LeanSigPublicKey, LeanSigSignature, ValidatorPublicKey, ValidatorSignature}, }; -use lean_multisig::{ - MultiMessageAggregateSignature as LMType2, ProofError, - SingleMessageAggregateSignature as LMType1, aggregate_single_message_signatures, - merge_single_message_aggregates, setup_prover, setup_verifier, split_multi_message_aggregate, - verify_multi_message_aggregate, verify_single_message_aggregate, +use rec_aggregation::{ + MultiMessageAggregateSignature as LMType2, SingleMessageAggregateSignature as LMType1, + aggregate_single_message_signatures, init_aggregation_bytecode, + merge_single_message_aggregates, split_multi_message_aggregate, verify_multi_message_aggregate, + verify_single_message_aggregate, }; -use leansig_wrapper::{XmssPublicKey as LeanSigPubKey, XmssSignature as LeanSigSignature}; use thiserror::Error; #[cfg(feature = "shadow-integration")] pub mod shadow_cost; -/// log(1/rate) for the WHIR commitment scheme used inside lean-multisig. +/// log(1/rate) for the WHIR commitment scheme used inside the aggregation prover. const LOG_INV_RATE: usize = 2; -// Lazy initialization for prover and verifier setup -static PROVER_INIT: Once = Once::new(); -static VERIFIER_INIT: Once = Once::new(); - -/// Ensure the prover is initialized. Safe to call multiple times. +/// Ensure the aggregation bytecode is compiled. Safe to call multiple times. +/// +/// leanVM's `main` replaced the old `setup_prover`/`setup_verifier` split with a +/// single self-referential aggregation bytecode used by both the prover and the +/// verifier; `init_aggregation_bytecode` is idempotent (`OnceLock::get_or_init`). pub fn ensure_prover_ready() { - PROVER_INIT.call_once(setup_prover); + init_aggregation_bytecode(); } -/// Ensure the verifier is initialized. Safe to call multiple times. +/// Ensure the aggregation bytecode is compiled. Safe to call multiple times. +/// +/// Deserializing any proof (`from_bytes`) also requires the bytecode, so this +/// must run before any verification path. pub fn ensure_verifier_ready() { - VERIFIER_INIT.call_once(setup_verifier); + init_aggregation_bytecode(); } /// Error type for signature aggregation operations. @@ -81,7 +81,10 @@ pub enum VerificationError { DeserializationFailed, #[error("verification failed: {0}")] - ProofError(#[from] ProofError), + VerificationFailed(String), + + #[error("aggregate binds a different validator set than expected at component {0}")] + PublicKeySetMismatch(usize), #[error( "(message, slot) mismatch: proof binds {got_slot}/{got_msg:?}, expected {expected_slot}/{expected_msg:?}" @@ -107,33 +110,45 @@ pub enum VerificationError { // Helpers // ===================================================================== -fn into_lean_pubkeys(pubkeys: Vec) -> Vec { - pubkeys +/// The sorted, de-duplicated set of native pubkeys, matching the canonical form +/// leanVM's aggregation embeds inside a proof's `info.pubkeys`. +fn sorted_dedup_pubkeys(pubkeys: Vec) -> Vec { + let mut keys: Vec = pubkeys .into_iter() .map(ValidatorPublicKey::into_inner) - .collect() + .collect(); + keys.sort(); + keys.dedup(); + keys } -/// Decompress a stored Type-1 proof (without-pubkeys form) into a native -/// `SingleMessageAggregateSignature` by attaching the resolved validator pubkeys. +/// Deserialize a stored Type-1 proof. +/// +/// leanVM's `main` embeds the participant pubkeys inside the serialized proof +/// (`info.pubkeys`), so decoding no longer needs the caller-supplied pubkeys. +/// They are still cross-checked against the proof's embedded set as an +/// integrity guard. fn decompress_type1( pubkeys: Vec, proof_bytes: &ByteList512KiB, index: usize, ) -> Result { - let lean_pks = into_lean_pubkeys(pubkeys); - LMType1::decompress_without_pubkeys(proof_bytes.iter().as_slice(), lean_pks) - .ok_or(AggregationError::ChildDeserializationFailed(index)) + let sig = LMType1::from_bytes(proof_bytes.iter().as_slice()) + .ok_or(AggregationError::ChildDeserializationFailed(index))?; + if sig.info.pubkeys != sorted_dedup_pubkeys(pubkeys) { + return Err(AggregationError::ChildDeserializationFailed(index)); + } + Ok(sig) } fn compress_type1_to_byte_list(sig: &LMType1) -> Result { - let serialized = sig.compress_without_pubkeys(); + let serialized = sig.to_bytes(); let len = serialized.len(); ByteList512KiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) } fn compress_type2_to_byte_list(sig: &LMType2) -> Result { - let serialized = sig.compress_without_pubkeys(); + let serialized = sig.to_bytes(); let len = serialized.len(); ByteList512KiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) } @@ -144,12 +159,14 @@ fn compress_type2_to_byte_list(sig: &LMType2) -> Result, signatures: Vec, @@ -181,7 +198,7 @@ pub fn aggregate_signatures( ensure_prover_ready(); - let raw_xmss: Vec<(LeanSigPubKey, LeanSigSignature)> = public_keys + let raw_xmss: Vec<(LeanSigPublicKey, LeanSigSignature)> = public_keys .into_iter() .zip(signatures) .map(|(pk, sig)| (pk.into_inner(), sig.into_inner())) @@ -242,7 +259,7 @@ pub fn aggregate_mixed( .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) .collect::>()?; - let raw_xmss: Vec<(LeanSigPubKey, LeanSigSignature)> = raw_public_keys + let raw_xmss: Vec<(LeanSigPublicKey, LeanSigSignature)> = raw_public_keys .into_iter() .zip(raw_signatures) .map(|(pk, sig)| (pk.into_inner(), sig.into_inner())) @@ -331,20 +348,25 @@ pub fn verify_aggregated_signature( } ensure_verifier_ready(); - let lean_pubkeys = into_lean_pubkeys(public_keys); - let sig = LMType1::decompress_without_pubkeys(proof_data.iter().as_slice(), lean_pubkeys) + let sig = LMType1::from_bytes(proof_data.iter().as_slice()) .ok_or(VerificationError::DeserializationFailed)?; - if sig.info.without_pubkeys.message != message.0 || sig.info.without_pubkeys.slot != slot { + if sig.info.message != message.0 || sig.info.slot != slot { return Err(VerificationError::BindingMismatch { expected_msg: *message, expected_slot: slot, - got_msg: H256(sig.info.without_pubkeys.message), - got_slot: sig.info.without_pubkeys.slot, + got_msg: H256(sig.info.message), + got_slot: sig.info.slot, }); } - verify_single_message_aggregate(&sig)?; + // The proof embeds its participant set; bind it to the caller's expectation. + if sig.info.pubkeys != sorted_dedup_pubkeys(public_keys) { + return Err(VerificationError::PublicKeySetMismatch(0)); + } + + verify_single_message_aggregate(&sig) + .map_err(|err| VerificationError::VerificationFailed(format!("{err:?}")))?; Ok(()) } @@ -355,11 +377,11 @@ pub fn verify_aggregated_signature( /// Merge many independent Type-1 multi-signatures into a single Type-2 proof. /// /// Each input is `(participant_pubkeys, type_1_proof_bytes)` where the bytes -/// are the `compress_without_pubkeys()` form of a `SingleMessageAggregateSignature`. +/// are the `to_bytes()` form of a `SingleMessageAggregateSignature`. The pubkeys +/// are also embedded in the proof; the caller-supplied set is cross-checked. /// -/// The returned blob is the `compress_without_pubkeys()` form of the resulting -/// `MultiMessageAggregateSignature`. A verifier decoding it back needs the per-component -/// pubkey sets in the same order. +/// The returned blob is the `to_bytes()` form of the resulting +/// `MultiMessageAggregateSignature`, with each component's pubkeys embedded. pub fn merge_type_1s_into_type_2( type_1s: Vec<(Vec, ByteList512KiB)>, ) -> Result { @@ -421,13 +443,12 @@ pub fn verify_type_2_signature( ensure_verifier_ready(); - let pubkeys_per_info: Vec> = pubkeys_per_component + let expected_pubkeys_per_info: Vec> = pubkeys_per_component .into_iter() - .map(into_lean_pubkeys) + .map(sorted_dedup_pubkeys) .collect(); - let sig = LMType2::decompress_without_pubkeys(proof_data, pubkeys_per_info) - .ok_or(VerificationError::DeserializationFailed)?; + let sig = LMType2::from_bytes(proof_data).ok_or(VerificationError::DeserializationFailed)?; if sig.info.len() != expected_bindings.len() { return Err(VerificationError::Type2ComponentCountMismatch { @@ -439,34 +460,35 @@ pub fn verify_type_2_signature( for (idx, ((expected_msg, expected_slot), info)) in expected_bindings.iter().zip(sig.info.iter()).enumerate() { - if info.without_pubkeys.message != expected_msg.0 - || info.without_pubkeys.slot != *expected_slot - { + if info.message != expected_msg.0 || info.slot != *expected_slot { return Err(VerificationError::BindingMismatch { expected_msg: *expected_msg, expected_slot: *expected_slot, - got_msg: H256(info.without_pubkeys.message), - got_slot: info.without_pubkeys.slot, + got_msg: H256(info.message), + got_slot: info.slot, }); } - let _ = idx; // index reserved for richer diagnostics if needed + // The proof embeds each component's participant set; bind it to the + // caller's expectation. + if info.pubkeys != expected_pubkeys_per_info[idx] { + return Err(VerificationError::PublicKeySetMismatch(idx)); + } } - verify_multi_message_aggregate(&sig)?; + verify_multi_message_aggregate(&sig) + .map_err(|err| VerificationError::VerificationFailed(format!("{err:?}")))?; Ok(()) } /// Split (disaggregate) a Type-2 merged proof into a single Type-1 proof for /// the component bound to `message`. Generates a fresh SNARK; expensive. /// -/// Mirrors leanSpec PR #717 `split_multi_message_aggregate_by_message`: the caller -/// supplies the expected message (an attestation data root or the block -/// root) and the wrapper locates the unique matching component inside the -/// decompressed proof. Returns the `compress_without_pubkeys()` form of the -/// resulting Type-1. +/// Mirrors `split_multi_message_aggregate_by_message`: the caller supplies the +/// expected message (an attestation data root or the block root) and the +/// wrapper locates the unique matching component inside the decoded proof. +/// Returns the `to_bytes()` form of the resulting Type-1. pub fn split_type_2_by_message( proof_data: &[u8], - pubkeys_per_component: Vec>, message: &H256, ) -> Result { #[cfg(feature = "shadow-integration")] @@ -479,19 +501,15 @@ pub fn split_type_2_by_message( ensure_prover_ready(); - let pubkeys_per_info: Vec> = pubkeys_per_component - .into_iter() - .map(into_lean_pubkeys) - .collect(); - - let type_2 = LMType2::decompress_without_pubkeys(proof_data, pubkeys_per_info) - .ok_or(AggregationError::DeserializationFailed)?; + // leanVM embeds each component's pubkeys in the serialized proof, so decoding + // no longer needs caller-supplied pubkey sets. + let type_2 = LMType2::from_bytes(proof_data).ok_or(AggregationError::DeserializationFailed)?; let matches: Vec = type_2 .info .iter() .enumerate() - .filter_map(|(i, info)| (info.without_pubkeys.message == message.0).then_some(i)) + .filter_map(|(i, info)| (info.message == message.0).then_some(i)) .collect(); let index = match matches.as_slice() { [i] => *i, @@ -508,43 +526,43 @@ pub fn split_type_2_by_message( #[cfg(test)] mod tests { use super::*; - use ethlambda_types::signature::LeanSignatureScheme; - use leansig::{serialization::Serializable, signature::SignatureScheme}; - use rand::{SeedableRng, rngs::StdRng}; + use ssz::Encode as _; + use xmss::{xmss_key_gen_from_seed, xmss_sign}; /// Generate a test keypair and sign a message. /// /// Note: This is slow because XMSS key generation is computationally expensive. - /// TODO: move to pre-generated keys fn generate_keypair_and_sign( seed: u64, activation_epoch: u32, signing_epoch: u32, message: &H256, ) -> (ValidatorPublicKey, ValidatorSignature) { - let mut rng = StdRng::seed_from_u64(seed); + let mut seed_bytes = [0u8; 32]; + seed_bytes[..8].copy_from_slice(&seed.to_le_bytes()); - // Use a small lifetime for faster test key generation - let log_lifetime = 5; // 2^5 = 32 epochs - let lifetime = 1 << log_lifetime; + // Small active range (starting at the activation epoch and covering the + // signing slot) for fast key generation. + let num_active_slots = 64u64; + let (pk, sk) = + xmss_key_gen_from_seed(seed_bytes, activation_epoch as u64, num_active_slots) + .expect("valid activation range"); - let (pk, sk) = LeanSignatureScheme::key_gen(&mut rng, activation_epoch as usize, lifetime); + let sig = xmss_sign(&sk, signing_epoch, &message.0).expect("sign"); - let sig = LeanSignatureScheme::sign(&sk, signing_epoch, &message.0).unwrap(); - - // Convert to ethlambda types via bytes - let pk_bytes = pk.to_bytes(); - let sig_bytes = sig.to_bytes(); - - let validator_pk = ValidatorPublicKey::from_bytes(&pk_bytes).unwrap(); - let validator_sig = ValidatorSignature::from_bytes(&sig_bytes).unwrap(); + // Convert to ethlambda types via SSZ wire bytes. + let validator_pk = ValidatorPublicKey::from_bytes(&pk.as_ssz_bytes()).unwrap(); + let validator_sig = ValidatorSignature::from_bytes(&sig.as_ssz_bytes()).unwrap(); (validator_pk, validator_sig) } #[test] + #[ignore = "slow: compiles the leanVM aggregation bytecode (needs a release-sized stack)"] fn test_setup_is_idempotent() { - // Should not panic when called multiple times + // Should not panic when called multiple times. The first call compiles + // the self-referential aggregation bytecode; subsequent calls are cheap + // (`OnceLock::get_or_init`). ensure_prover_ready(); ensure_prover_ready(); ensure_verifier_ready(); @@ -681,12 +699,7 @@ mod tests { ) .expect("verify type-2"); - let split = split_type_2_by_message( - merged.iter().as_slice(), - vec![vec![pk_a.clone()], vec![pk_b.clone()]], - &msg_a, - ) - .expect("split"); + let split = split_type_2_by_message(merged.iter().as_slice(), &msg_a).expect("split"); verify_aggregated_signature(&split, vec![pk_a.clone()], &msg_a, slot_a) .expect("verify split"); diff --git a/crates/common/test-fixtures/src/common.rs b/crates/common/test-fixtures/src/common.rs index 6293b897..5bad0bab 100644 --- a/crates/common/test-fixtures/src/common.rs +++ b/crates/common/test-fixtures/src/common.rs @@ -330,7 +330,12 @@ where let pubkey: ValidatorPubkeyBytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) .map_err(|_| D::Error::custom("ValidatorPubkey value is not valid hex"))? .try_into() - .map_err(|_| D::Error::custom("ValidatorPubkey length != 52"))?; + .map_err(|_| { + D::Error::custom(format!( + "ValidatorPubkey length != {}", + ethlambda_types::signature::PUBLIC_KEY_SIZE + )) + })?; Ok(pubkey) } diff --git a/crates/common/types/Cargo.toml b/crates/common/types/Cargo.toml index 1de09a67..e7a733d6 100644 --- a/crates/common/types/Cargo.toml +++ b/crates/common/types/Cargo.toml @@ -14,7 +14,9 @@ thiserror.workspace = true serde.workspace = true hex.workspace = true -leansig.workspace = true +xmss.workspace = true +ssz.workspace = true +postcard.workspace = true libssz.workspace = true libssz-derive.workspace = true diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 0d68addb..f8706697 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -58,34 +58,15 @@ pub struct SignedAttestation { /// XMSS signature as a fixed-length byte vector (`SIGNATURE_SIZE` bytes). pub type XmssSignature = SszVector; -/// SSZ offset (in bytes) of the `path` body inside an XMSS `Signature` container. -/// -/// Layout: 4-byte path offset + 28-byte rho + 4-byte hashes offset = 36. -const SIGNATURE_PATH_OFFSET: u32 = 36; - -/// SSZ offset (in bytes) of the `hashes` body inside an XMSS `Signature`. -/// -/// `path` body is 4-byte siblings offset + LOG_LIFETIME (32) siblings × 32-byte -/// digest = 1028, starting at byte 36, so hashes start at 36 + 1028 = 1064. -const SIGNATURE_HASHES_OFFSET: u32 = 1064; - -/// SSZ offset (in bytes) of the `siblings` list inside the `path` container. -const SIGNATURE_PATH_SIBLINGS_OFFSET: u32 = 4; - -/// Build a placeholder XMSS signature that decodes as a structurally valid -/// leanSpec `Signature` container of all-zero hashes. +/// Build a placeholder XMSS signature of all-zero field elements. /// /// Used for genesis-style anchor blocks that were never proposed and therefore -/// have no real signature. Parent containers inline this as an opaque -/// `SIGNATURE_SIZE`-byte blob; consumers that decode the inner `Signature` -/// container see `path = HashTreeOpening { siblings = [0; 32] }`, `rho = 0`, -/// `hashes = [0; 46]`. Matches ream's `Signature::blank()` so the wire format -/// is byte-identical across clients. +/// have no real signature. leanVM's `XmssSignature` SSZ-encodes as a +/// fixed-length sequence of field elements (no variable-length offsets), and a +/// zero word is a valid canonical field element, so an all-zero blob decodes as +/// a structurally valid (but unverifiable) signature. pub fn blank_xmss_signature() -> XmssSignature { - let mut bytes = vec![0u8; SIGNATURE_SIZE]; - bytes[..4].copy_from_slice(&SIGNATURE_PATH_OFFSET.to_le_bytes()); - bytes[32..36].copy_from_slice(&SIGNATURE_HASHES_OFFSET.to_le_bytes()); - bytes[36..40].copy_from_slice(&SIGNATURE_PATH_SIBLINGS_OFFSET.to_le_bytes()); + let bytes = vec![0u8; SIGNATURE_SIZE]; XmssSignature::try_from(bytes).expect("size matches SIGNATURE_SIZE") } @@ -311,35 +292,14 @@ mod tests { assert!(!bits_is_subset(&a, &b)); } - /// Guard the three SSZ offsets at fixed byte positions so a one-off in any - /// constant doesn't silently produce a blob that still has the right outer - /// length but decodes incorrectly at the inner `Signature` container level. + /// The blank placeholder is exactly `SIGNATURE_SIZE` all-zero bytes and + /// decodes back as a structurally valid signature. #[test] - fn blank_xmss_signature_has_expected_ssz_offsets() { + fn blank_xmss_signature_is_zero_and_decodes() { let sig = blank_xmss_signature(); let bytes: Vec = sig.into_iter().collect(); assert_eq!(bytes.len(), SIGNATURE_SIZE); - assert_eq!( - u32::from_le_bytes(bytes[0..4].try_into().unwrap()), - SIGNATURE_PATH_OFFSET, - ); - assert_eq!( - u32::from_le_bytes(bytes[32..36].try_into().unwrap()), - SIGNATURE_HASHES_OFFSET, - ); - assert_eq!( - u32::from_le_bytes(bytes[36..40].try_into().unwrap()), - SIGNATURE_PATH_SIBLINGS_OFFSET, - ); - - // Everything outside the three offset slots must be zero — the - // placeholder is "all-zero hashes" once the offsets locate them. - for (i, b) in bytes.iter().enumerate() { - let in_offset_slot = matches!(i, 0..4 | 32..36 | 36..40); - if !in_offset_slot { - assert_eq!(*b, 0, "non-offset byte at index {i} should be zero"); - } - } + assert!(bytes.iter().all(|b| *b == 0)); } } diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 5c5508a2..c2d69174 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -48,12 +48,13 @@ pub type ByteList512KiB = ByteList<524_288>; /// A merged proof covering multiple messages with a single proof blob. /// -/// Also known as a *type-2* proof: the lean-multisig term for an aggregate -/// binding several distinct messages, each with its own participant set. +/// Also known as a *type-2* proof: leanVM's term for an aggregate binding +/// several distinct messages, each with its own participant set. /// -/// The proof bytes use lean-multisig's compact public-key-free -/// representation. SSZ encoding this container adds the offset required for -/// its variable-length field. +/// The proof bytes are the `to_bytes()` (postcard) form of leanVM's +/// `MultiMessageAggregateSignature`, which embeds each component's participant +/// pubkeys. SSZ encoding this container adds the offset required for its +/// variable-length field. #[derive(Debug, Default, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] pub struct MultiMessageAggregate { /// Serialized multi-message aggregate proof bytes. @@ -66,7 +67,7 @@ impl MultiMessageAggregate { Self { proof } } - /// Copy raw lean-multisig proof bytes into the bounded SSZ container. + /// Copy raw leanVM aggregation proof bytes into the bounded SSZ container. pub fn from_bytes(bytes: &[u8]) -> Result { let len = bytes.len(); ByteList512KiB::try_from(bytes.to_vec()) @@ -74,7 +75,7 @@ impl MultiMessageAggregate { .map_err(|_| MultiMessageAggregateError::ProofTooLarge(len)) } - /// Return the raw lean-multisig proof bytes. + /// Return the raw leanVM aggregation proof bytes. pub fn proof_bytes(&self) -> &[u8] { self.proof.iter().as_slice() } @@ -98,7 +99,7 @@ pub enum MultiMessageAggregateError { // from the surrounding block body (attestation `data` + slot for body // components, block root + slot for the proposer component). // -// `MultiMessageAggregate` carries the raw lean-multisig type-2 bytes. +// `MultiMessageAggregate` carries the raw leanVM type-2 bytes. // Component participant bitfields come from // `block.body.attestations[i].aggregation_bits` (and `block.proposer_index` for // the trailing proposer entry). @@ -110,8 +111,8 @@ pub const MAX_ATTESTATIONS_DATA: usize = 8; /// A single-message proof aggregating signatures from many validators. /// -/// Also known as a *type-1* proof: the lean-multisig term for an aggregate -/// where every participant signs the same message for the same slot. +/// Also known as a *type-1* proof: leanVM's term for an aggregate where every +/// participant signs the same message for the same slot. /// /// Used: /// - as a gossip-level `SignedAggregatedAttestation.proof`, @@ -119,15 +120,16 @@ pub const MAX_ATTESTATIONS_DATA: usize = 8; /// - as one of the components fed into `merge_type_1s_into_type_2` when /// building a block proof. /// -/// `participants` and `proof` are independent fields: the proof bytes are -/// the lean-multisig `compress_without_pubkeys()` form; `participants` is -/// the bitfield identifying which validators are bound by the proof. The -/// verifier resolves pubkeys from `participants` at verify time. +/// `participants` and `proof` are independent fields: the proof bytes are the +/// `to_bytes()` form of leanVM's `SingleMessageAggregateSignature` (which +/// embeds the participant pubkeys); `participants` is the bitfield identifying +/// which validators are bound. The verifier resolves the expected pubkeys from +/// `participants` and cross-checks them against the set embedded in the proof. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct SingleMessageAggregate { /// Bitfield identifying validators bound by this proof. pub participants: AggregationBits, - /// Aggregated proof bytes in lean-multisig compact (no-pubkeys) form. + /// Aggregated proof bytes in leanVM's `to_bytes()` form (pubkeys embedded). pub proof: ByteList512KiB, } diff --git a/crates/common/types/src/genesis.rs b/crates/common/types/src/genesis.rs index 27baebf6..02b2bbab 100644 --- a/crates/common/types/src/genesis.rs +++ b/crates/common/types/src/genesis.rs @@ -44,7 +44,11 @@ where let bytes = hex::decode(s).map_err(|_| D::Error::custom(format!("pubkey is not valid hex: {s}")))?; bytes.try_into().map_err(|v: Vec| { - D::Error::custom(format!("pubkey has length {} (expected 52)", v.len())) + D::Error::custom(format!( + "pubkey has length {} (expected {})", + v.len(), + crate::signature::PUBLIC_KEY_SIZE + )) }) } @@ -56,12 +60,12 @@ mod tests { state::{State, Validator}, }; - const ATT_PUBKEY_A: &str = "cd323f232b34ab26d6db7402c886e74ca81cfd3a0c659d2fe022356f25592f7d2d25ca7b19604f5a180037046cf2a02e1da4a800"; - const PROP_PUBKEY_A: &str = "b7b0f72e24801b02bda64073cb4de6699a416b37dfead227d7ca3922647c940fa03e4c012e8a0e656b731934aeac124a5337e333"; - const ATT_PUBKEY_B: &str = "8d9cbc508b20ef43e165f8559c1bdd18aaeda805ef565a4f9ffd6e4fbed01c05e143e305017847445859650d6dd06e6efb3f8410"; - const PROP_PUBKEY_B: &str = "cd323f232b34ab26d6db7402c886e74ca81cfd3a0c659d2fe022356f25592f7d2d25ca7b19604f5a180037046cf2a02e1da4a800"; - const ATT_PUBKEY_C: &str = "b7b0f72e24801b02bda64073cb4de6699a416b37dfead227d7ca3922647c940fa03e4c012e8a0e656b731934aeac124a5337e333"; - const PROP_PUBKEY_C: &str = "8d9cbc508b20ef43e165f8559c1bdd18aaeda805ef565a4f9ffd6e4fbed01c05e143e305017847445859650d6dd06e6efb3f8410"; + const ATT_PUBKEY_A: &str = "cd323f232b34ab26d6db7402c886e74ca81cfd3a0c659d2fe022356f25592f7d"; + const PROP_PUBKEY_A: &str = "b7b0f72e24801b02bda64073cb4de6699a416b37dfead227d7ca3922647c940f"; + const ATT_PUBKEY_B: &str = "8d9cbc508b20ef43e165f8559c1bdd18aaeda805ef565a4f9ffd6e4fbed01c05"; + const PROP_PUBKEY_B: &str = "cd323f232b34ab26d6db7402c886e74ca81cfd3a0c659d2fe022356f25592f7d"; + const ATT_PUBKEY_C: &str = "b7b0f72e24801b02bda64073cb4de6699a416b37dfead227d7ca3922647c940f"; + const PROP_PUBKEY_C: &str = "8d9cbc508b20ef43e165f8559c1bdd18aaeda805ef565a4f9ffd6e4fbed01c05"; const TEST_CONFIG_YAML: &str = r#"# Genesis Settings GENESIS_TIME: 1770407233 @@ -74,12 +78,12 @@ VALIDATOR_COUNT: 3 # Genesis Validator Pubkeys GENESIS_VALIDATORS: - - attestation_pubkey: "cd323f232b34ab26d6db7402c886e74ca81cfd3a0c659d2fe022356f25592f7d2d25ca7b19604f5a180037046cf2a02e1da4a800" - proposal_pubkey: "b7b0f72e24801b02bda64073cb4de6699a416b37dfead227d7ca3922647c940fa03e4c012e8a0e656b731934aeac124a5337e333" - - attestation_pubkey: "8d9cbc508b20ef43e165f8559c1bdd18aaeda805ef565a4f9ffd6e4fbed01c05e143e305017847445859650d6dd06e6efb3f8410" - proposal_pubkey: "cd323f232b34ab26d6db7402c886e74ca81cfd3a0c659d2fe022356f25592f7d2d25ca7b19604f5a180037046cf2a02e1da4a800" - - attestation_pubkey: "b7b0f72e24801b02bda64073cb4de6699a416b37dfead227d7ca3922647c940fa03e4c012e8a0e656b731934aeac124a5337e333" - proposal_pubkey: "8d9cbc508b20ef43e165f8559c1bdd18aaeda805ef565a4f9ffd6e4fbed01c05e143e305017847445859650d6dd06e6efb3f8410" + - attestation_pubkey: "cd323f232b34ab26d6db7402c886e74ca81cfd3a0c659d2fe022356f25592f7d" + proposal_pubkey: "b7b0f72e24801b02bda64073cb4de6699a416b37dfead227d7ca3922647c940f" + - attestation_pubkey: "8d9cbc508b20ef43e165f8559c1bdd18aaeda805ef565a4f9ffd6e4fbed01c05" + proposal_pubkey: "cd323f232b34ab26d6db7402c886e74ca81cfd3a0c659d2fe022356f25592f7d" + - attestation_pubkey: "b7b0f72e24801b02bda64073cb4de6699a416b37dfead227d7ca3922647c940f" + proposal_pubkey: "8d9cbc508b20ef43e165f8559c1bdd18aaeda805ef565a4f9ffd6e4fbed01c05" "#; #[test] @@ -146,7 +150,7 @@ GENESIS_VALIDATORS: // Pin the state root so SSZ layout changes are caught immediately. let expected_state_root = crate::primitives::H256::from_slice( - &hex::decode("babcdc9235a29dfc0d605961df51cfc85732f85291c2beea8b7510a92ec458fe") + &hex::decode("3e8c8507e94e045327c2fc66a58db374805cb490a087b3101bb13a9b8b611b54") .unwrap(), ); assert_eq!(root, expected_state_root, "state root mismatch"); @@ -155,7 +159,7 @@ GENESIS_VALIDATORS: block.state_root = root; let block_root = block.hash_tree_root(); let expected_block_root = crate::primitives::H256::from_slice( - &hex::decode("66a8beaa81d2aaeac7212d4bf8f5fea2bd22d479566a33a83c891661c21235ef") + &hex::decode("ba3502921697db025b3a6d7c05fbaf58e52155575438cca9794e22e6e9872090") .unwrap(), ); assert_eq!(block_root, expected_block_root, "block root mismatch"); diff --git a/crates/common/types/src/signature.rs b/crates/common/types/src/signature.rs index fa9c8da8..766a756d 100644 --- a/crates/common/types/src/signature.rs +++ b/crates/common/types/src/signature.rs @@ -1,34 +1,32 @@ use std::ops::Range; -use leansig::{ - serialization::Serializable, - signature::{SignatureScheme, SignatureSchemeSecretKey as _, SigningError}, +use ssz::{Decode, Encode}; +use xmss::{ + PUB_KEY_SSZ_LEN, SIGNATURE_SSZ_LEN, XmssPublicKey, XmssSecretKey, XmssSignature, + XmssSignatureError, xmss_sign, xmss_verify, }; use crate::primitives::H256; -/// The XMSS signature scheme used for validator signatures. -/// -/// This is a post-quantum secure signature scheme based on hash functions. -/// Uses Poseidon1 hashing with an aborting hypercube message hash, -/// 32-bit lifetime (2^32 signatures per key), dimension 46, and base 8. -pub type LeanSignatureScheme = leansig::signature::generalized_xmss::instantiations_aborting::lifetime_2_to_the_32::SIGAbortingTargetSumLifetime32Dim46Base8; - -/// The public key type from the leansig library. -pub type LeanSigPublicKey = ::PublicKey; +/// The public key type from leanVM's xmss crate. +pub type LeanSigPublicKey = XmssPublicKey; -/// The signature type from the leansig library. -pub type LeanSigSignature = ::Signature; +/// The signature type from leanVM's xmss crate. +pub type LeanSigSignature = XmssSignature; -/// The secret key type from the leansig library. -pub type LeanSigSecretKey = ::SecretKey; +/// The secret key type from leanVM's xmss crate. +pub type LeanSigSecretKey = XmssSecretKey; pub type Signature = LeanSigSignature; -/// Size of an XMSS signature in bytes. +/// Size of an SSZ-encoded XMSS signature in bytes. /// -/// Computed from: path(32*8*4) + rho(7*4) + hashes(46*8*4) + ssz_offsets(3*4) = 2536 -pub const SIGNATURE_SIZE: usize = 2536; +/// Sourced from leanVM's xmss crate rather than hardcoded, so it tracks the +/// scheme parameters (`WOTS_SIG_SIZE_FE`, `LOG_LIFETIME`, `XMSS_DIGEST_LEN`). +pub const SIGNATURE_SIZE: usize = SIGNATURE_SSZ_LEN; + +/// Size of an SSZ-encoded XMSS public key in bytes. +pub const PUBLIC_KEY_SIZE: usize = PUB_KEY_SSZ_LEN; /// Error returned when parsing signature or key bytes fails. #[derive(Debug, Clone, thiserror::Error)] @@ -41,18 +39,20 @@ pub struct ValidatorSignature { } impl ValidatorSignature { + /// Parse from the SSZ-encoded wire form (`SIGNATURE_SIZE` bytes). pub fn from_bytes(bytes: &[u8]) -> Result { - let sig = LeanSigSignature::from_bytes(bytes) + let sig = LeanSigSignature::from_ssz_bytes(bytes) .map_err(|e| SignatureParseError(format!("{e:?}")))?; Ok(Self { inner: sig }) } + /// Encode to the SSZ wire form (`SIGNATURE_SIZE` bytes). pub fn to_bytes(&self) -> Vec { - self.inner.to_bytes() + self.inner.as_ssz_bytes() } pub fn is_valid(&self, pubkey: &ValidatorPublicKey, slot: u32, message: &H256) -> bool { - LeanSignatureScheme::verify(&pubkey.inner, slot, &message.0, &self.inner) + xmss_verify(&pubkey.inner, slot, &message.0, &self.inner).is_ok() } pub fn into_inner(self) -> LeanSigSignature { @@ -66,14 +66,16 @@ pub struct ValidatorPublicKey { } impl ValidatorPublicKey { + /// Parse from the SSZ-encoded wire form (`PUBLIC_KEY_SIZE` bytes). pub fn from_bytes(bytes: &[u8]) -> Result { - let pk = LeanSigPublicKey::from_bytes(bytes) + let pk = LeanSigPublicKey::from_ssz_bytes(bytes) .map_err(|e| SignatureParseError(format!("{e:?}")))?; Ok(Self { inner: pk }) } + /// Encode to the SSZ wire form (`PUBLIC_KEY_SIZE` bytes). pub fn to_bytes(&self) -> Vec { - self.inner.to_bytes() + self.inner.as_ssz_bytes() } pub fn into_inner(self) -> LeanSigPublicKey { @@ -87,100 +89,116 @@ pub struct ValidatorSecretKey { } impl ValidatorSecretKey { + /// Parse from the postcard-encoded key file produced by the genesis generator. + /// + /// leanVM's `XmssSecretKey` uses serde (postcard) rather than the SSZ-style + /// `Serializable` of the old leanSig scheme. pub fn from_bytes(bytes: &[u8]) -> Result { - let sk = LeanSigSecretKey::from_bytes(bytes) + let sk = postcard::from_bytes::(bytes) .map_err(|e| SignatureParseError(format!("{e:?}")))?; Ok(Self { inner: sk }) } - /// Sign a message with this private key. + /// Serialize the secret key to its postcard key-file form. + pub fn to_bytes(&self) -> Result, SignatureParseError> { + postcard::to_allocvec(&self.inner).map_err(|e| SignatureParseError(format!("{e:?}"))) + } + + /// The public key derived from this secret key. + pub fn public_key(&self) -> ValidatorPublicKey { + ValidatorPublicKey { + inner: self.inner.public_key(), + } + } + + /// Sign a message at `slot`. /// - /// The slot is used as part of the XMSS signature scheme to track - /// one-time signature usage. - pub fn sign(&self, slot: u32, message: &H256) -> Result { - let sig = LeanSignatureScheme::sign(&self.inner, slot, &message.0)?; + /// The slot indexes the one-time XMSS leaf; never sign two different + /// messages at the same slot. + pub fn sign( + &self, + slot: u32, + message: &H256, + ) -> Result { + let sig = xmss_sign(&self.inner, slot, &message.0)?; Ok(ValidatorSignature { inner: sig }) } - /// Returns true if the key is prepared to sign at the given slot. + /// Returns true if the key can sign at the given slot. /// - /// XMSS keys maintain a sliding window of two bottom trees. Only slots - /// within this window can be signed without advancing the preparation. + /// leanVM's xmss keys cover a fixed activation range (fixed at key + /// generation); there is no sliding preparation window as in the old + /// leanSig scheme. pub fn is_prepared_for(&self, slot: u32) -> bool { - self.inner.get_prepared_interval().contains(&(slot as u64)) + self.inner.activation_slots().contains(&slot) } - /// Returns the slot range currently covered by the prepared window. + /// The half-open slot range this key can sign for. pub fn get_prepared_interval(&self) -> Range { - self.inner.get_prepared_interval() + let range = self.inner.activation_slots(); + (*range.start() as u64)..(*range.end() as u64 + 1) } - /// Advance the prepared window forward by one bottom tree. + /// No-op retained for API compatibility. /// - /// Each call slides the window by sqrt(LIFETIME) = 65,536 slots. - /// If the window is already at the end of the key's activation interval, - /// this is a no-op. - pub fn advance_preparation(&mut self) { - self.inner.advance_preparation(); - } + /// The old leanSig scheme advanced a two-bottom-tree preparation window; + /// leanVM's xmss keys have a fixed activation range and warm their signing + /// cache on demand inside `sign`, so there is nothing to advance. + pub fn advance_preparation(&mut self) {} } #[cfg(test)] mod tests { use super::*; - use leansig::serialization::Serializable; - use rand::{SeedableRng, rngs::StdRng}; - - const LEAVES_PER_BOTTOM_TREE: u32 = 1 << 16; // 65,536 - - /// Generate a ValidatorSecretKey with 3 bottom trees so advance_preparation can be tested. - /// - /// This is slow (~minutes) because it computes 3 bottom trees of 65,536 leaves each. - fn generate_key_with_three_bottom_trees() -> ValidatorSecretKey { - let mut rng = StdRng::seed_from_u64(42); - // Request enough active epochs for 3 bottom trees (> 2 * 65,536) - let num_active_epochs = (LEAVES_PER_BOTTOM_TREE as usize) * 2 + 1; - let (_pk, sk) = LeanSignatureScheme::key_gen(&mut rng, 0, num_active_epochs); - let sk_bytes = sk.to_bytes(); - ValidatorSecretKey::from_bytes(&sk_bytes).expect("valid secret key") + use xmss::xmss_key_gen_from_seed; + + /// Generate a validator key pair over a small activation range. + fn generate_key( + seed: [u8; 32], + activation_slot: u64, + num_active_slots: u64, + ) -> ValidatorSecretKey { + let (_pk, sk) = xmss_key_gen_from_seed(seed, activation_slot, num_active_slots) + .expect("valid activation range"); + ValidatorSecretKey { inner: sk } } #[test] - #[ignore = "slow: generates production-size XMSS key (~minutes)"] - fn test_advance_preparation_duration() { - println!("Generating XMSS key with 3 bottom trees (this takes a while)..."); - let keygen_start = std::time::Instant::now(); - let mut sk = generate_key_with_three_bottom_trees(); - println!("Key generation took: {:?}", keygen_start.elapsed()); - - // Initial window covers [0, 131072) - assert!(sk.is_prepared_for(0)); - assert!(sk.is_prepared_for(LEAVES_PER_BOTTOM_TREE - 1)); - assert!(sk.is_prepared_for(2 * LEAVES_PER_BOTTOM_TREE - 1)); - assert!(!sk.is_prepared_for(2 * LEAVES_PER_BOTTOM_TREE)); + #[ignore = "slow: XMSS key generation and signing"] + fn sign_verify_round_trip() { + let sk = generate_key([7u8; 32], 0, 64); + let pk = sk.public_key(); - // Time the advance_preparation call - let advance_start = std::time::Instant::now(); - sk.advance_preparation(); - let advance_duration = advance_start.elapsed(); + assert!(sk.is_prepared_for(0)); + assert!(sk.is_prepared_for(63)); + assert!(!sk.is_prepared_for(64)); + assert_eq!(sk.get_prepared_interval(), 0..64); - println!("advance_preparation() took: {advance_duration:?}"); + let message = H256::from([42u8; 32]); + let slot = 10u32; + let sig = sk.sign(slot, &message).expect("sign"); + assert!(sig.is_valid(&pk, slot, &message)); + assert!(!sig.is_valid(&pk, slot, &H256::from([43u8; 32]))); + assert!(!sig.is_valid(&pk, slot + 1, &message)); + } - // Window should now cover [65536, 196608) - assert!(!sk.is_prepared_for(0)); - assert!(sk.is_prepared_for(LEAVES_PER_BOTTOM_TREE)); - assert!(sk.is_prepared_for(3 * LEAVES_PER_BOTTOM_TREE - 1)); + #[test] + #[ignore = "slow: XMSS key generation and signing"] + fn sign_out_of_range_fails() { + let sk = generate_key([9u8; 32], 100, 32); + let message = H256::from([1u8; 32]); + // Slot 0 is outside the key's activation range [100, 132). + assert!(sk.sign(0, &message).is_err()); + } - // Verify signing works in the new window - let message = H256::from([42u8; 32]); - let slot = 2 * LEAVES_PER_BOTTOM_TREE; // slot 131,072 — the one that crashed the devnet - let sign_start = std::time::Instant::now(); - let result = sk.sign(slot, &message); - println!("Signing at slot {slot} took: {:?}", sign_start.elapsed()); - assert!( - result.is_ok(), - "signing should succeed after advance: {}", - result.err().map_or(String::new(), |e| e.to_string()) - ); + #[test] + #[ignore = "slow: XMSS key generation"] + fn public_key_ssz_round_trip() { + let sk = generate_key([3u8; 32], 0, 16); + let pk = sk.public_key(); + let bytes = pk.to_bytes(); + assert_eq!(bytes.len(), PUBLIC_KEY_SIZE); + let parsed = ValidatorPublicKey::from_bytes(&bytes).expect("round trip"); + assert_eq!(parsed.to_bytes(), bytes); } } diff --git a/crates/common/types/src/state.rs b/crates/common/types/src/state.rs index 26ff110d..58494922 100644 --- a/crates/common/types/src/state.rs +++ b/crates/common/types/src/state.rs @@ -6,7 +6,7 @@ use crate::{ block::{Block, BlockBody, BlockHeader}, checkpoint::Checkpoint, primitives::{self, H256}, - signature::{SignatureParseError, ValidatorPublicKey}, + signature::{PUBLIC_KEY_SIZE, SignatureParseError, ValidatorPublicKey}, }; // Convenience trait for calling hash_tree_root() without a hasher argument @@ -95,7 +95,7 @@ impl Validator { } } -pub type ValidatorPubkeyBytes = [u8; 52]; +pub type ValidatorPubkeyBytes = [u8; PUBLIC_KEY_SIZE]; impl State { pub fn from_genesis(genesis_time: u64, validators: Vec) -> Self { diff --git a/crates/net/rpc/src/genesis.rs b/crates/net/rpc/src/genesis.rs index aa7472c9..6ab0b59e 100644 --- a/crates/net/rpc/src/genesis.rs +++ b/crates/net/rpc/src/genesis.rs @@ -42,8 +42,8 @@ mod tests { async fn genesis_returns_time_and_validator_count() { // Build a state with 3 validators so the assertion is non-vacuous. let dummy_validator = |index: u64| Validator { - attestation_pubkey: [0u8; 52], - proposal_pubkey: [0u8; 52], + attestation_pubkey: [0u8; 32], + proposal_pubkey: [0u8; 32], index, }; let validators = vec![dummy_validator(0), dummy_validator(1), dummy_validator(2)]; diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 7381a53e..2bc1968a 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -24,5 +24,4 @@ lru.workspace = true [dev-dependencies] tempfile = "3" -leansig.workspace = true rand.workspace = true diff --git a/crates/storage/src/state_diff.rs b/crates/storage/src/state_diff.rs index 1e9e5028..261619f8 100644 --- a/crates/storage/src/state_diff.rs +++ b/crates/storage/src/state_diff.rs @@ -248,13 +248,13 @@ mod tests { fn base_state() -> State { let validators = vec![ Validator { - attestation_pubkey: [1u8; 52], - proposal_pubkey: [2u8; 52], + attestation_pubkey: [1u8; 32], + proposal_pubkey: [2u8; 32], index: 0, }, Validator { - attestation_pubkey: [3u8; 52], - proposal_pubkey: [4u8; 52], + attestation_pubkey: [3u8; 32], + proposal_pubkey: [4u8; 32], index: 1, }, ]; diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index a320406f..c5eac04f 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -1798,8 +1798,8 @@ mod tests { /// post-state's `latest_block_header`). fn sample_state(slot: u64, parent_root: H256, hbh: Vec) -> State { let validators = vec![Validator { - attestation_pubkey: [7u8; 52], - proposal_pubkey: [9u8; 52], + attestation_pubkey: [7u8; 32], + proposal_pubkey: [9u8; 32], index: 0, }]; let mut state = State::from_genesis(1_000, validators); @@ -2484,19 +2484,12 @@ mod tests { // ============ GossipSignatureBuffer Tests ============ fn make_dummy_sig() -> ValidatorSignature { - use ethlambda_types::signature::LeanSignatureScheme; - use leansig::{serialization::Serializable, signature::SignatureScheme}; - use rand::{SeedableRng, rngs::StdRng}; - - static CACHED_SIG: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - let mut rng = StdRng::seed_from_u64(42); - let lifetime = 1 << 5; // small for speed - let (_pk, sk) = LeanSignatureScheme::key_gen(&mut rng, 0, lifetime); - let sig = LeanSignatureScheme::sign(&sk, 0, &[0u8; 32]).unwrap(); - sig.to_bytes() - }); - - ValidatorSignature::from_bytes(&CACHED_SIG).expect("cached test signature") + // These tests never check signature validity, only that a signature + // decodes and carries through the buffers. An all-zero blob is a + // structurally valid (unverifiable) XMSS signature. + use ethlambda_types::signature::SIGNATURE_SIZE; + ValidatorSignature::from_bytes(&vec![0u8; SIGNATURE_SIZE]) + .expect("all-zero test signature decodes") } #[test] From 6b41823a281ddf5062c737c192064d18d5c75265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:09:18 -0300 Subject: [PATCH 02/20] build(deps): pin leanVM to main HEAD (a73ab11) instead of tracking the branch Tracking `branch = main` re-resolves on every fetch, so the build could change under us. Pin the current main HEAD for reproducibility; the comment notes it is main's tip and the rev can be bumped to move forward. --- Cargo.lock | 38 +++++++++++++++++++------------------- Cargo.toml | 8 +++++--- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9063761..a0e2fc67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,7 +88,7 @@ dependencies = [ [[package]] name = "air" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "field", "poly", @@ -661,7 +661,7 @@ dependencies = [ [[package]] name = "backend" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "air", "fiat-shamir", @@ -2564,7 +2564,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "fiat-shamir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "field", "koala-bear", @@ -2577,7 +2577,7 @@ dependencies = [ [[package]] name = "field" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "parallel", "paste", @@ -3647,7 +3647,7 @@ dependencies = [ [[package]] name = "koala-bear" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "field", "paste", @@ -3709,7 +3709,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lean_compiler" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", "include_dir", @@ -3723,7 +3723,7 @@ dependencies = [ [[package]] name = "lean_prover" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", "lean_compiler", @@ -3738,7 +3738,7 @@ dependencies = [ [[package]] name = "lean_vm" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", "tracing", @@ -5290,7 +5290,7 @@ dependencies = [ [[package]] name = "parallel" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "system-info", ] @@ -5485,7 +5485,7 @@ dependencies = [ [[package]] name = "poly" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "field", "parallel", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "rec_aggregation" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", "include_dir", @@ -7097,7 +7097,7 @@ dependencies = [ [[package]] name = "sub_protocols" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", "lean_vm", @@ -7113,7 +7113,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sumcheck" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "air", "fiat-shamir", @@ -7127,7 +7127,7 @@ dependencies = [ [[package]] name = "symetric" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "field", "koala-bear", @@ -7215,7 +7215,7 @@ dependencies = [ [[package]] name = "system-info" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "libc", ] @@ -7748,7 +7748,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utils" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "parallel", "serde", @@ -8012,7 +8012,7 @@ dependencies = [ [[package]] name = "whir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "fiat-shamir", "field", @@ -8570,7 +8570,7 @@ dependencies = [ [[package]] name = "xmss" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "backend", "ethereum_ssz", @@ -8745,7 +8745,7 @@ dependencies = [ [[package]] name = "zk-alloc" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?branch=main#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" dependencies = [ "libc", "parallel", diff --git a/Cargo.toml b/Cargo.toml index 28fc5b82..97cc5a6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,11 +55,13 @@ prometheus = "0.14" clap = { version = "4.3", features = ["derive", "env"] } -# XMSS signatures + recursive aggregation (leanVM, main branch). +# XMSS signatures + recursive aggregation (leanVM). # leanVM's `main` internalized XMSS (dropping the external leanSig dependency) # and reworked the aggregation API (see "Xmss api rework"). -xmss = { git = "https://github.com/leanEthereum/leanVM.git", branch = "main" } -rec_aggregation = { git = "https://github.com/leanEthereum/leanVM.git", branch = "main" } +# Pinned to the current `main` HEAD (branch `main`, commit a73ab11) for +# reproducible builds; bump the rev to track main forward. +xmss = { git = "https://github.com/leanEthereum/leanVM.git", rev = "a73ab114721441aa833384e791f3b83801257456" } +rec_aggregation = { git = "https://github.com/leanEthereum/leanVM.git", rev = "a73ab114721441aa833384e791f3b83801257456" } # SSZ codec used by leanVM's xmss pubkey/signature types (ethereum_ssz, not libssz). ssz = { package = "ethereum_ssz", version = "0.10" } From cd293eb9ad3ace43b2bbfb5aacedb556f702b74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:13:35 -0300 Subject: [PATCH 03/20] build(deps): bump leanVM pin to c83b40f Moves the pin two commits forward from a73ab11 (`Xmss api rework`, #262): - `105c5c69` rec_aggregation: optional pubkey-less aggregate serialization - `c83b40f0` fully single-threaded verifier (`Vec` instead of `ArenaVec`), plus a `forbid-parallelism` feature that asserts it `105c5c69` split `SingleMessageInfo` into a `SingleMessageCore` (message, slot, bytecode claim) plus the pubkey set, so the binding checks in the Type-1 and Type-2 verify paths reach through `.core`. The `xmss` crate is byte-identical between the two revs, so key and pubkey formats are unchanged: the genesis keys generated for a73ab11 verify as-is. `c83b40f0` makes proof verification sequential rather than pool-dispatched. The `forbid_parallelism()` guards it adds are inert unless leanVM's `forbid-parallelism` feature is enabled, which we do not enable. Validated on a 3-node all-ethlambda devnet with fresh new-format genesis: real leanVM proving on every block, Type-2 aggregates verified from gossip in ~50ms, finality advancing one slot per slot, no errors. --- Cargo.lock | 44 ++++++++++++++++----------------- Cargo.toml | 8 +++--- crates/common/crypto/src/lib.rs | 14 +++++------ 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0e2fc67..20c6e160 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,7 +88,7 @@ dependencies = [ [[package]] name = "air" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "field", "poly", @@ -661,7 +661,7 @@ dependencies = [ [[package]] name = "backend" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "air", "fiat-shamir", @@ -1570,7 +1570,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2564,7 +2564,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "fiat-shamir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "field", "koala-bear", @@ -2577,9 +2577,8 @@ dependencies = [ [[package]] name = "field" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ - "parallel", "paste", "rand 0.10.1", "serde", @@ -3647,7 +3646,7 @@ dependencies = [ [[package]] name = "koala-bear" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "field", "paste", @@ -3709,7 +3708,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lean_compiler" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "backend", "include_dir", @@ -3723,7 +3722,7 @@ dependencies = [ [[package]] name = "lean_prover" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "backend", "lean_compiler", @@ -3738,7 +3737,7 @@ dependencies = [ [[package]] name = "lean_vm" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "backend", "tracing", @@ -5290,7 +5289,7 @@ dependencies = [ [[package]] name = "parallel" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "system-info", ] @@ -5485,7 +5484,7 @@ dependencies = [ [[package]] name = "poly" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "field", "parallel", @@ -6068,7 +6067,7 @@ dependencies = [ [[package]] name = "rec_aggregation" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "backend", "include_dir", @@ -6077,6 +6076,7 @@ dependencies = [ "lean_vm", "objc2", "objc2-foundation", + "parallel", "postcard", "serde", "sub_protocols", @@ -7097,7 +7097,7 @@ dependencies = [ [[package]] name = "sub_protocols" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "backend", "lean_vm", @@ -7113,7 +7113,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sumcheck" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "air", "fiat-shamir", @@ -7127,7 +7127,7 @@ dependencies = [ [[package]] name = "symetric" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "field", "koala-bear", @@ -7215,7 +7215,7 @@ dependencies = [ [[package]] name = "system-info" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "libc", ] @@ -7239,7 +7239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -7748,7 +7748,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utils" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "parallel", "serde", @@ -8012,7 +8012,7 @@ dependencies = [ [[package]] name = "whir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "fiat-shamir", "field", @@ -8570,7 +8570,7 @@ dependencies = [ [[package]] name = "xmss" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "backend", "ethereum_ssz", @@ -8745,7 +8745,7 @@ dependencies = [ [[package]] name = "zk-alloc" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a73ab114721441aa833384e791f3b83801257456#a73ab114721441aa833384e791f3b83801257456" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" dependencies = [ "libc", "parallel", diff --git a/Cargo.toml b/Cargo.toml index 97cc5a6a..556a3fcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,10 +58,10 @@ clap = { version = "4.3", features = ["derive", "env"] } # XMSS signatures + recursive aggregation (leanVM). # leanVM's `main` internalized XMSS (dropping the external leanSig dependency) # and reworked the aggregation API (see "Xmss api rework"). -# Pinned to the current `main` HEAD (branch `main`, commit a73ab11) for -# reproducible builds; bump the rev to track main forward. -xmss = { git = "https://github.com/leanEthereum/leanVM.git", rev = "a73ab114721441aa833384e791f3b83801257456" } -rec_aggregation = { git = "https://github.com/leanEthereum/leanVM.git", rev = "a73ab114721441aa833384e791f3b83801257456" } +# Pinned to a `main` commit (`c83b40f`) for reproducible builds; bump the rev to +# track main forward. +xmss = { git = "https://github.com/leanEthereum/leanVM.git", rev = "c83b40f01e809f1bd3238d17a821a1ff3ed8171d" } +rec_aggregation = { git = "https://github.com/leanEthereum/leanVM.git", rev = "c83b40f01e809f1bd3238d17a821a1ff3ed8171d" } # SSZ codec used by leanVM's xmss pubkey/signature types (ethereum_ssz, not libssz). ssz = { package = "ethereum_ssz", version = "0.10" } diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index ca1dfa61..10225dd1 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -351,12 +351,12 @@ pub fn verify_aggregated_signature( let sig = LMType1::from_bytes(proof_data.iter().as_slice()) .ok_or(VerificationError::DeserializationFailed)?; - if sig.info.message != message.0 || sig.info.slot != slot { + if sig.info.core.message != message.0 || sig.info.core.slot != slot { return Err(VerificationError::BindingMismatch { expected_msg: *message, expected_slot: slot, - got_msg: H256(sig.info.message), - got_slot: sig.info.slot, + got_msg: H256(sig.info.core.message), + got_slot: sig.info.core.slot, }); } @@ -460,12 +460,12 @@ pub fn verify_type_2_signature( for (idx, ((expected_msg, expected_slot), info)) in expected_bindings.iter().zip(sig.info.iter()).enumerate() { - if info.message != expected_msg.0 || info.slot != *expected_slot { + if info.core.message != expected_msg.0 || info.core.slot != *expected_slot { return Err(VerificationError::BindingMismatch { expected_msg: *expected_msg, expected_slot: *expected_slot, - got_msg: H256(info.message), - got_slot: info.slot, + got_msg: H256(info.core.message), + got_slot: info.core.slot, }); } // The proof embeds each component's participant set; bind it to the @@ -509,7 +509,7 @@ pub fn split_type_2_by_message( .info .iter() .enumerate() - .filter_map(|(i, info)| (info.message == message.0).then_some(i)) + .filter_map(|(i, info)| (info.core.message == message.0).then_some(i)) .collect(); let index = match matches.as_slice() { [i] => *i, From d68b246f14e945b572cd040c34d22431e58ddb00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:02:37 -0300 Subject: [PATCH 04/20] feat(crypto)!: keep pubkeys off the proof wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leanVM `105c5c69` restored optional pubkey-less aggregate serialization, so the wire format no longer has to change relative to `main`. Proofs serialize via `to_bytes_without_pubkeys()` and the caller attaches the resolved signer set at decode with `from_bytes_without_pubkeys()`, exactly as `compress_without_pubkeys()`/`decompress_without_pubkeys()` did before the migration. Every public signature in the crypto crate now matches `main` again, including `split_type_2_by_message`, which regains its `pubkeys_per_component` argument (`reaggregate.rs` already resolves that layout for its merge step, so the call site just passes it through). Attaching the caller's set is what binds a proof to its participants: leanVM sorts and de-duplicates it, then the SNARK checks it against the hash the proof commits to. So the explicit `PublicKeySetMismatch` comparison the embedded-pubkey form needed is gone, and a wrong set now fails inside the verifier — covered by a new `test_verify_wrong_pubkey_set_fails`. Validated on a 3-node all-ethlambda devnet: 114 block Type-2 aggregates decoded and verified through the pubkey-less path (111 carrying attestation components), p50 43.7ms, finality tracking head at 3 slots, no errors. Reaggregation never triggered there, so `split_type_2_by_message` is covered by the Type-2 merge/verify/split round-trip test instead. --- crates/blockchain/src/reaggregate.rs | 7 +- crates/common/crypto/src/lib.rs | 129 +++++++++++++++------------ 2 files changed, 78 insertions(+), 58 deletions(-) diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs index 92ff3108..f47d6c51 100644 --- a/crates/blockchain/src/reaggregate.rs +++ b/crates/blockchain/src/reaggregate.rs @@ -122,8 +122,11 @@ pub fn reaggregate_from_block( // Step 1: SNARK-split this attestation's component out of the block's // merged multi-message aggregate proof. let merged_bytes = signed_block.proof.proof_bytes(); - let split_bytes = match ethlambda_crypto::split_type_2_by_message(merged_bytes, &data_root) - { + let split_bytes = match ethlambda_crypto::split_type_2_by_message( + merged_bytes, + pubkeys_per_component.clone(), + &data_root, + ) { Ok(bytes) => bytes, Err(err) => { debug!(%err, data_root = %ethlambda_types::ShortRoot(&data_root.0), diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 10225dd1..1ae4907a 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -83,9 +83,6 @@ pub enum VerificationError { #[error("verification failed: {0}")] VerificationFailed(String), - #[error("aggregate binds a different validator set than expected at component {0}")] - PublicKeySetMismatch(usize), - #[error( "(message, slot) mismatch: proof binds {got_slot}/{got_msg:?}, expected {expected_slot}/{expected_msg:?}" )] @@ -110,45 +107,37 @@ pub enum VerificationError { // Helpers // ===================================================================== -/// The sorted, de-duplicated set of native pubkeys, matching the canonical form -/// leanVM's aggregation embeds inside a proof's `info.pubkeys`. -fn sorted_dedup_pubkeys(pubkeys: Vec) -> Vec { - let mut keys: Vec = pubkeys +fn into_lean_pubkeys(pubkeys: Vec) -> Vec { + pubkeys .into_iter() .map(ValidatorPublicKey::into_inner) - .collect(); - keys.sort(); - keys.dedup(); - keys + .collect() } -/// Deserialize a stored Type-1 proof. +/// Decompress a stored Type-1 proof (without-pubkeys form) into a native +/// `SingleMessageAggregateSignature` by attaching the resolved validator pubkeys. /// -/// leanVM's `main` embeds the participant pubkeys inside the serialized proof -/// (`info.pubkeys`), so decoding no longer needs the caller-supplied pubkeys. -/// They are still cross-checked against the proof's embedded set as an -/// integrity guard. +/// leanVM sorts and de-duplicates the attached set itself, so the order here does +/// not matter. A set other than the one aggregated attaches fine but fails +/// verification: the proof binds a hash of the set. fn decompress_type1( pubkeys: Vec, proof_bytes: &ByteList512KiB, index: usize, ) -> Result { - let sig = LMType1::from_bytes(proof_bytes.iter().as_slice()) - .ok_or(AggregationError::ChildDeserializationFailed(index))?; - if sig.info.pubkeys != sorted_dedup_pubkeys(pubkeys) { - return Err(AggregationError::ChildDeserializationFailed(index)); - } - Ok(sig) + let lean_pks = into_lean_pubkeys(pubkeys); + LMType1::from_bytes_without_pubkeys(proof_bytes.iter().as_slice(), lean_pks) + .ok_or(AggregationError::ChildDeserializationFailed(index)) } fn compress_type1_to_byte_list(sig: &LMType1) -> Result { - let serialized = sig.to_bytes(); + let serialized = sig.to_bytes_without_pubkeys(); let len = serialized.len(); ByteList512KiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) } fn compress_type2_to_byte_list(sig: &LMType2) -> Result { - let serialized = sig.to_bytes(); + let serialized = sig.to_bytes_without_pubkeys(); let len = serialized.len(); ByteList512KiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) } @@ -164,9 +153,10 @@ fn compress_type2_to_byte_list(sig: &LMType2) -> Result, signatures: Vec, @@ -348,7 +338,8 @@ pub fn verify_aggregated_signature( } ensure_verifier_ready(); - let sig = LMType1::from_bytes(proof_data.iter().as_slice()) + let lean_pubkeys = into_lean_pubkeys(public_keys); + let sig = LMType1::from_bytes_without_pubkeys(proof_data.iter().as_slice(), lean_pubkeys) .ok_or(VerificationError::DeserializationFailed)?; if sig.info.core.message != message.0 || sig.info.core.slot != slot { @@ -360,11 +351,6 @@ pub fn verify_aggregated_signature( }); } - // The proof embeds its participant set; bind it to the caller's expectation. - if sig.info.pubkeys != sorted_dedup_pubkeys(public_keys) { - return Err(VerificationError::PublicKeySetMismatch(0)); - } - verify_single_message_aggregate(&sig) .map_err(|err| VerificationError::VerificationFailed(format!("{err:?}")))?; Ok(()) @@ -377,11 +363,11 @@ pub fn verify_aggregated_signature( /// Merge many independent Type-1 multi-signatures into a single Type-2 proof. /// /// Each input is `(participant_pubkeys, type_1_proof_bytes)` where the bytes -/// are the `to_bytes()` form of a `SingleMessageAggregateSignature`. The pubkeys -/// are also embedded in the proof; the caller-supplied set is cross-checked. +/// are the `to_bytes_without_pubkeys()` form of a `SingleMessageAggregateSignature`. /// -/// The returned blob is the `to_bytes()` form of the resulting -/// `MultiMessageAggregateSignature`, with each component's pubkeys embedded. +/// The returned blob is the `to_bytes_without_pubkeys()` form of the resulting +/// `MultiMessageAggregateSignature`. A verifier decoding it back needs the per-component +/// pubkey sets in the same order. pub fn merge_type_1s_into_type_2( type_1s: Vec<(Vec, ByteList512KiB)>, ) -> Result { @@ -443,12 +429,13 @@ pub fn verify_type_2_signature( ensure_verifier_ready(); - let expected_pubkeys_per_info: Vec> = pubkeys_per_component + let pubkeys_per_info: Vec> = pubkeys_per_component .into_iter() - .map(sorted_dedup_pubkeys) + .map(into_lean_pubkeys) .collect(); - let sig = LMType2::from_bytes(proof_data).ok_or(VerificationError::DeserializationFailed)?; + let sig = LMType2::from_bytes_without_pubkeys(proof_data, pubkeys_per_info) + .ok_or(VerificationError::DeserializationFailed)?; if sig.info.len() != expected_bindings.len() { return Err(VerificationError::Type2ComponentCountMismatch { @@ -457,9 +444,7 @@ pub fn verify_type_2_signature( }); } - for (idx, ((expected_msg, expected_slot), info)) in - expected_bindings.iter().zip(sig.info.iter()).enumerate() - { + for ((expected_msg, expected_slot), info) in expected_bindings.iter().zip(sig.info.iter()) { if info.core.message != expected_msg.0 || info.core.slot != *expected_slot { return Err(VerificationError::BindingMismatch { expected_msg: *expected_msg, @@ -468,11 +453,6 @@ pub fn verify_type_2_signature( got_slot: info.core.slot, }); } - // The proof embeds each component's participant set; bind it to the - // caller's expectation. - if info.pubkeys != expected_pubkeys_per_info[idx] { - return Err(VerificationError::PublicKeySetMismatch(idx)); - } } verify_multi_message_aggregate(&sig) @@ -483,12 +463,17 @@ pub fn verify_type_2_signature( /// Split (disaggregate) a Type-2 merged proof into a single Type-1 proof for /// the component bound to `message`. Generates a fresh SNARK; expensive. /// -/// Mirrors `split_multi_message_aggregate_by_message`: the caller supplies the -/// expected message (an attestation data root or the block root) and the -/// wrapper locates the unique matching component inside the decoded proof. -/// Returns the `to_bytes()` form of the resulting Type-1. +/// Mirrors leanSpec PR #717 `split_multi_message_aggregate_by_message`: the caller +/// supplies the expected message (an attestation data root or the block +/// root) and the wrapper locates the unique matching component inside the +/// decompressed proof. Returns the `to_bytes_without_pubkeys()` form of the +/// resulting Type-1. +/// +/// `pubkeys_per_component` gives one signer set per component, in the proof's +/// component order; they are not on the wire, so decoding needs them. pub fn split_type_2_by_message( proof_data: &[u8], + pubkeys_per_component: Vec>, message: &H256, ) -> Result { #[cfg(feature = "shadow-integration")] @@ -501,9 +486,13 @@ pub fn split_type_2_by_message( ensure_prover_ready(); - // leanVM embeds each component's pubkeys in the serialized proof, so decoding - // no longer needs caller-supplied pubkey sets. - let type_2 = LMType2::from_bytes(proof_data).ok_or(AggregationError::DeserializationFailed)?; + let pubkeys_per_info: Vec> = pubkeys_per_component + .into_iter() + .map(into_lean_pubkeys) + .collect(); + + let type_2 = LMType2::from_bytes_without_pubkeys(proof_data, pubkeys_per_info) + .ok_or(AggregationError::DeserializationFailed)?; let matches: Vec = type_2 .info @@ -671,6 +660,29 @@ mod tests { ); } + /// The signer set is not carried on the wire, so it is the caller-supplied + /// set that binds a proof to its participants. Supplying a different set of + /// the same size must be rejected: the proof commits to a hash of the set, + /// so it fails inside the SNARK verifier rather than at decode. + #[test] + #[ignore = "too slow"] + fn test_verify_wrong_pubkey_set_fails() { + let message = H256::from([42u8; 32]); + let slot = 10u32; + + let (pk, sig) = generate_keypair_and_sign(1, 5, slot, &message); + let (other_pk, _) = generate_keypair_and_sign(2, 5, slot, &message); + + let proof_data = aggregate_signatures(vec![pk], vec![sig], &message, slot).unwrap(); + + let verify_result = + verify_aggregated_signature(&proof_data, vec![other_pk], &message, slot); + assert!( + verify_result.is_err(), + "Verification should have failed with a different signer set" + ); + } + /// End-to-end Type-2 round-trip: produce two Type-1s (different (msg, slot)), /// merge them into a Type-2, verify the Type-2, then split out one component /// and verify it as a Type-1. @@ -699,7 +711,12 @@ mod tests { ) .expect("verify type-2"); - let split = split_type_2_by_message(merged.iter().as_slice(), &msg_a).expect("split"); + let split = split_type_2_by_message( + merged.iter().as_slice(), + vec![vec![pk_a.clone()], vec![pk_b.clone()]], + &msg_a, + ) + .expect("split"); verify_aggregated_signature(&split, vec![pk_a.clone()], &msg_a, slot_a) .expect("verify split"); From db7bc133f5e66a45bcd7ce065152fdbfe43b136b Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Mon, 27 Jul 2026 19:58:01 +0100 Subject: [PATCH 05/20] fix(crypto): use leanVM's facade and prove one at a time `ensure_prover_ready` only called `init_aggregation_bytecode`, so the arena was never engaged and every prover buffer used the system allocator. `setup_prover` lives in the `lean-multisig` facade, not in `rec_aggregation`, so importing crate by crate hid it. Engaging the arena arms a panic: only one proof may run at a time. Take a permit around each proving entry point; without it 5 of the 7 crypto tests panic. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 61 +++++++++++++++++++++------------ Cargo.toml | 7 ++-- crates/common/crypto/Cargo.toml | 6 ++-- crates/common/crypto/src/lib.rs | 42 +++++++++++++++-------- 4 files changed, 72 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 20c6e160..b99ebe33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,9 +88,10 @@ dependencies = [ [[package]] name = "air" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "field", + "koala-bear", "poly", ] @@ -661,7 +662,7 @@ dependencies = [ [[package]] name = "backend" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "air", "fiat-shamir", @@ -2068,10 +2069,10 @@ dependencies = [ "ethereum_ssz", "ethlambda-types", "hex", + "lean-multisig", "rand 0.10.1", - "rec_aggregation", "thiserror 2.0.18", - "xmss", + "tracing", ] [[package]] @@ -2564,7 +2565,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "fiat-shamir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "field", "koala-bear", @@ -2577,7 +2578,7 @@ dependencies = [ [[package]] name = "field" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "paste", "rand 0.10.1", @@ -3646,7 +3647,7 @@ dependencies = [ [[package]] name = "koala-bear" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "field", "paste", @@ -3705,10 +3706,26 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lean-multisig" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +dependencies = [ + "backend", + "clap", + "lean_vm", + "rec_aggregation", + "serde_json", + "sub_protocols", + "system-info", + "xmss", + "zk-alloc", +] + [[package]] name = "lean_compiler" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "backend", "include_dir", @@ -3722,7 +3739,7 @@ dependencies = [ [[package]] name = "lean_prover" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "backend", "lean_compiler", @@ -3737,7 +3754,7 @@ dependencies = [ [[package]] name = "lean_vm" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "backend", "tracing", @@ -5289,7 +5306,7 @@ dependencies = [ [[package]] name = "parallel" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "system-info", ] @@ -5484,9 +5501,10 @@ dependencies = [ [[package]] name = "poly" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "field", + "koala-bear", "parallel", "rand 0.10.1", "serde", @@ -6067,7 +6085,7 @@ dependencies = [ [[package]] name = "rec_aggregation" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "backend", "include_dir", @@ -7097,7 +7115,7 @@ dependencies = [ [[package]] name = "sub_protocols" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "backend", "lean_vm", @@ -7113,11 +7131,12 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sumcheck" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "air", "fiat-shamir", "field", + "koala-bear", "parallel", "poly", "tracing", @@ -7127,7 +7146,7 @@ dependencies = [ [[package]] name = "symetric" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "field", "koala-bear", @@ -7215,7 +7234,7 @@ dependencies = [ [[package]] name = "system-info" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "libc", ] @@ -7748,7 +7767,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utils" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "parallel", "serde", @@ -8012,7 +8031,7 @@ dependencies = [ [[package]] name = "whir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "fiat-shamir", "field", @@ -8570,7 +8589,7 @@ dependencies = [ [[package]] name = "xmss" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "backend", "ethereum_ssz", @@ -8745,7 +8764,7 @@ dependencies = [ [[package]] name = "zk-alloc" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=c83b40f01e809f1bd3238d17a821a1ff3ed8171d#c83b40f01e809f1bd3238d17a821a1ff3ed8171d" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" dependencies = [ "libc", "parallel", diff --git a/Cargo.toml b/Cargo.toml index 556a3fcc..b201f875 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,10 +58,9 @@ clap = { version = "4.3", features = ["derive", "env"] } # XMSS signatures + recursive aggregation (leanVM). # leanVM's `main` internalized XMSS (dropping the external leanSig dependency) # and reworked the aggregation API (see "Xmss api rework"). -# Pinned to a `main` commit (`c83b40f`) for reproducible builds; bump the rev to -# track main forward. -xmss = { git = "https://github.com/leanEthereum/leanVM.git", rev = "c83b40f01e809f1bd3238d17a821a1ff3ed8171d" } -rec_aggregation = { git = "https://github.com/leanEthereum/leanVM.git", rev = "c83b40f01e809f1bd3238d17a821a1ff3ed8171d" } +# Pinned to a `main` commit for reproducible builds; bump the revs to track main. +xmss = { git = "https://github.com/leanEthereum/leanVM.git", rev = "380da82c330eac2841252a655a86ea48ec173bb6" } +lean-multisig = { git = "https://github.com/leanEthereum/leanVM.git", rev = "380da82c330eac2841252a655a86ea48ec173bb6" } # SSZ codec used by leanVM's xmss pubkey/signature types (ethereum_ssz, not libssz). ssz = { package = "ethereum_ssz", version = "0.10" } diff --git a/crates/common/crypto/Cargo.toml b/crates/common/crypto/Cargo.toml index 83291605..57db14dd 100644 --- a/crates/common/crypto/Cargo.toml +++ b/crates/common/crypto/Cargo.toml @@ -12,13 +12,11 @@ version.workspace = true [dependencies] ethlambda-types.workspace = true -# rec_aggregation is leanVM's recursive XMSS aggregation prover/verifier; -# xmss provides the public-key/signature types its API consumes. -rec_aggregation.workspace = true -xmss.workspace = true +lean-multisig.workspace = true thiserror.workspace = true rand.workspace = true +tracing.workspace = true [features] shadow-integration = [] diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 1ae4907a..39eb75d0 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -3,13 +3,15 @@ use ethlambda_types::{ primitives::H256, signature::{LeanSigPublicKey, LeanSigSignature, ValidatorPublicKey, ValidatorSignature}, }; -use rec_aggregation::{ +use lean_multisig::{ MultiMessageAggregateSignature as LMType2, SingleMessageAggregateSignature as LMType1, - aggregate_single_message_signatures, init_aggregation_bytecode, - merge_single_message_aggregates, split_multi_message_aggregate, verify_multi_message_aggregate, + aggregate_single_message_signatures, merge_single_message_aggregates, setup_prover, + setup_verifier, split_multi_message_aggregate, verify_multi_message_aggregate, verify_single_message_aggregate, }; +use std::sync::{Mutex, MutexGuard}; use thiserror::Error; +use tracing::error; #[cfg(feature = "shadow-integration")] pub mod shadow_cost; @@ -17,21 +19,26 @@ pub mod shadow_cost; /// log(1/rate) for the WHIR commitment scheme used inside the aggregation prover. const LOG_INV_RATE: usize = 2; -/// Ensure the aggregation bytecode is compiled. Safe to call multiple times. -/// -/// leanVM's `main` replaced the old `setup_prover`/`setup_verifier` split with a -/// single self-referential aggregation bytecode used by both the prover and the -/// verifier; `init_aggregation_bytecode` is idempotent (`OnceLock::get_or_init`). +/// leanVM allows one proof at a time per process; a second one panics. +static PROVER_PERMIT: Mutex<()> = Mutex::new(()); + +/// The permit guards no data, so a poisoned lock is recovered rather than propagated: +/// one panicking prover must not brick every later proof. It is still an incident. +fn prover_permit() -> MutexGuard<'static, ()> { + PROVER_PERMIT.lock().unwrap_or_else(|poisoned| { + error!("a previous proving job panicked while holding the permit; continuing"); + poisoned.into_inner() + }) +} + +/// Engages leanVM's arena; skipping it stays correct but slow. pub fn ensure_prover_ready() { - init_aggregation_bytecode(); + setup_prover(); } -/// Ensure the aggregation bytecode is compiled. Safe to call multiple times. -/// -/// Deserializing any proof (`from_bytes`) also requires the bytecode, so this -/// must run before any verification path. +/// Needed before any decode, not just before verifying. pub fn ensure_verifier_ready() { - init_aggregation_bytecode(); + setup_verifier(); } /// Error type for signature aggregation operations. @@ -187,6 +194,7 @@ pub fn aggregate_signatures( } ensure_prover_ready(); + let _permit = prover_permit(); let raw_xmss: Vec<(LeanSigPublicKey, LeanSigSignature)> = public_keys .into_iter() @@ -242,6 +250,7 @@ pub fn aggregate_mixed( } ensure_prover_ready(); + let _permit = prover_permit(); let children_native: Vec = children .into_iter() @@ -296,6 +305,7 @@ pub fn aggregate_proofs( } ensure_prover_ready(); + let _permit = prover_permit(); let children_native: Vec = children .into_iter() @@ -391,6 +401,7 @@ pub fn merge_type_1s_into_type_2( } ensure_prover_ready(); + let _permit = prover_permit(); let type_1s_native: Vec = type_1s .into_iter() @@ -485,6 +496,7 @@ pub fn split_type_2_by_message( } ensure_prover_ready(); + let _permit = prover_permit(); let pubkeys_per_info: Vec> = pubkeys_per_component .into_iter() @@ -515,8 +527,8 @@ pub fn split_type_2_by_message( #[cfg(test)] mod tests { use super::*; + use lean_multisig::{xmss_key_gen_from_seed, xmss_sign}; use ssz::Encode as _; - use xmss::{xmss_key_gen_from_seed, xmss_sign}; /// Generate a test keypair and sign a message. /// From fcd4a428ada859e88b973b20cdad248a2e48d780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:31:28 -0300 Subject: [PATCH 06/20] refactor(types): drop xmss dep, static-assert wire sizes in crypto `ethlambda-types` pulled in leanVM's `xmss` crate solely to source two integers, dragging the whole signing backend into the dependency graph of a crate that only describes wire formats. Hardcode `SIGNATURE_SIZE` and `PUBLIC_KEY_SIZE` there instead, and pin them to the scheme in `ethlambda-crypto` -- the one crate that sees both sides -- with const assertions. A leanVM bump that changes the XMSS parameters now fails to compile with a named error rather than silently resizing every signature and pubkey on the wire. --- Cargo.lock | 1 - crates/common/crypto/src/signature.rs | 21 ++++++++++++++++++--- crates/common/types/Cargo.toml | 4 ---- crates/common/types/src/attestation.rs | 11 ++++++----- crates/common/types/src/state.rs | 11 ++++++----- 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf2ae454..ab8ee7a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2213,7 +2213,6 @@ dependencies = [ "serde_json", "serde_yaml_ng", "thiserror 2.0.18", - "xmss", ] [[package]] diff --git a/crates/common/crypto/src/signature.rs b/crates/common/crypto/src/signature.rs index 507938e3..ceb3f067 100644 --- a/crates/common/crypto/src/signature.rs +++ b/crates/common/crypto/src/signature.rs @@ -3,12 +3,28 @@ use std::ops::Range; -use ethlambda_types::primitives::H256; +use ethlambda_types::{attestation::SIGNATURE_SIZE, primitives::H256, state::PUBLIC_KEY_SIZE}; use ssz::{Decode, Encode}; use xmss::{ - XmssPublicKey, XmssSecretKey, XmssSignature, XmssSignatureError, xmss_sign, xmss_verify, + PUB_KEY_SSZ_LEN, SIGNATURE_SSZ_LEN, XmssPublicKey, XmssSecretKey, XmssSignature, + XmssSignatureError, xmss_sign, xmss_verify, }; +// `ethlambda-types` hardcodes the XMSS wire sizes so it can stay free of the +// signing backend. This crate is the only place that sees both sides, so it is +// where they get pinned together: a leanVM bump that changes the scheme +// parameters fails to compile here instead of silently corrupting the wire +// format (every `XmssSignature` / `ValidatorPubkeyBytes` in `ethlambda-types` is +// sized by these constants). +const _: () = assert!( + SIGNATURE_SIZE == SIGNATURE_SSZ_LEN, + "ethlambda_types::attestation::SIGNATURE_SIZE no longer matches xmss::SIGNATURE_SSZ_LEN", +); +const _: () = assert!( + PUBLIC_KEY_SIZE == PUB_KEY_SSZ_LEN, + "ethlambda_types::state::PUBLIC_KEY_SIZE no longer matches xmss::PUB_KEY_SSZ_LEN", +); + /// The public key type from leanVM's xmss crate. pub type LeanSigPublicKey = XmssPublicKey; @@ -142,7 +158,6 @@ impl ValidatorSecretKey { #[cfg(test)] mod tests { use super::*; - use ethlambda_types::state::PUBLIC_KEY_SIZE; use xmss::xmss_key_gen_from_seed; /// Generate a validator key pair over a small activation range. diff --git a/crates/common/types/Cargo.toml b/crates/common/types/Cargo.toml index 787f855c..383f77a3 100644 --- a/crates/common/types/Cargo.toml +++ b/crates/common/types/Cargo.toml @@ -14,10 +14,6 @@ thiserror.workspace = true serde.workspace = true hex.workspace = true -# Only for the XMSS wire-size constants (PUB_KEY_SSZ_LEN / SIGNATURE_SSZ_LEN); -# the signing primitives themselves live in ethlambda-crypto. -xmss.workspace = true - libssz.workspace = true libssz-derive.workspace = true libssz-merkle.workspace = true diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 562248a1..e4555235 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -56,11 +56,12 @@ pub struct SignedAttestation { /// Size of an SSZ-encoded XMSS signature in bytes. /// -/// Sourced from leanVM's xmss crate rather than hardcoded, so it tracks the -/// scheme parameters (`WOTS_SIG_SIZE_FE`, `LOG_LIFETIME`, `XMSS_DIGEST_LEN`). -/// This is the SSZ wire size, so it lives here rather than in -/// `ethlambda-crypto`: parent containers only need the length. -pub const SIGNATURE_SIZE: usize = xmss::SIGNATURE_SSZ_LEN; +/// Mirrors leanVM's `xmss::SIGNATURE_SSZ_LEN`, hardcoded so this crate stays +/// free of the signing backend: parent containers only need the length, not the +/// scheme. `ethlambda-crypto` static-asserts the two agree, so a leanVM bump +/// that changes the scheme parameters breaks the build rather than silently +/// producing blobs of the wrong length. +pub const SIGNATURE_SIZE: usize = 1208; /// XMSS signature as a fixed-length byte vector (`SIGNATURE_SIZE` bytes). pub type XmssSignature = SszVector; diff --git a/crates/common/types/src/state.rs b/crates/common/types/src/state.rs index f82abda4..e1525e01 100644 --- a/crates/common/types/src/state.rs +++ b/crates/common/types/src/state.rs @@ -86,11 +86,12 @@ where /// Size of an SSZ-encoded XMSS public key in bytes. /// -/// Sourced from leanVM's xmss crate rather than hardcoded, so it tracks the -/// scheme parameters. Lives here rather than in `ethlambda-crypto` because the -/// validator registry is a plain wire type that must not depend on the signing -/// backend. -pub const PUBLIC_KEY_SIZE: usize = xmss::PUB_KEY_SSZ_LEN; +/// Mirrors leanVM's `xmss::PUB_KEY_SSZ_LEN`, hardcoded so this crate stays free +/// of the signing backend: the validator registry is a plain wire type. +/// `ethlambda-crypto` static-asserts the two agree, so a leanVM bump that +/// changes the scheme parameters breaks the build rather than silently +/// producing keys of the wrong length. +pub const PUBLIC_KEY_SIZE: usize = 32; pub type ValidatorPubkeyBytes = [u8; PUBLIC_KEY_SIZE]; From 7fb26d29b7d87cec9cab744ef9e244d9a920b0f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:39:18 -0300 Subject: [PATCH 07/20] refactor(crypto): report both sizes when the XMSS wire-size guard fires The const assertions named the mismatched constants but not their values: a const panic message must be a string literal, so it cannot interpolate the two numbers a reader needs in order to fix the constant. Express the check as an array-length mismatch instead. rustc then evaluates both sides and prints them ("expected an array with a size of 2536, found one with a size of 1208"), so the corrected value is right there in the error. A named constant would read better than `const _` but trips `dead_code`, which is denied in CI, so the intent lives in the comment -- including a warning not to take rustc's suggestion to edit the array length, which would silence the guard rather than fix the constant. --- crates/common/crypto/src/signature.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/common/crypto/src/signature.rs b/crates/common/crypto/src/signature.rs index ceb3f067..f4e5820b 100644 --- a/crates/common/crypto/src/signature.rs +++ b/crates/common/crypto/src/signature.rs @@ -16,14 +16,16 @@ use xmss::{ // parameters fails to compile here instead of silently corrupting the wire // format (every `XmssSignature` / `ValidatorPubkeyBytes` in `ethlambda-types` is // sized by these constants). -const _: () = assert!( - SIGNATURE_SIZE == SIGNATURE_SSZ_LEN, - "ethlambda_types::attestation::SIGNATURE_SIZE no longer matches xmss::SIGNATURE_SSZ_LEN", -); -const _: () = assert!( - PUBLIC_KEY_SIZE == PUB_KEY_SSZ_LEN, - "ethlambda_types::state::PUBLIC_KEY_SIZE no longer matches xmss::PUB_KEY_SSZ_LEN", -); +// +// Written as an array-length mismatch rather than `assert!` so that rustc prints +// both evaluated sizes; a const panic message has to be a string literal and so +// cannot name the numbers that actually differ. +// +// When one of these fires, correct the constant in `ethlambda-types` to the size +// rustc reports. Do not take rustc's `help:` suggestion to edit the length on +// these lines, which only silences the check. +const _: [(); SIGNATURE_SIZE] = [(); SIGNATURE_SSZ_LEN]; +const _: [(); PUBLIC_KEY_SIZE] = [(); PUB_KEY_SSZ_LEN]; /// The public key type from leanVM's xmss crate. pub type LeanSigPublicKey = XmssPublicKey; From 80080a0b07a1c8dfc1bf360d494f5118f2468b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:27:14 -0300 Subject: [PATCH 08/20] build(deps): track leanVM a5909d1 and prove on the system allocator leanVM's `setup_prover` engages a bump arena whose slabs never go back to the OS: `free` is a no-op and `begin_phase` only rewinds the per-thread bump pointers, so a node's RSS ratchets up to its allocation high-water mark and stays there whether or not it keeps proving. The new rev adds `setup_prover_without_arena`, which initializes the same prover on the system allocator. Proving is slower; memory is bounded and proofs are identical, which is the trade a long-lived node wants. --- Cargo.lock | 56 ++++++++++++++++----------------- Cargo.toml | 4 +-- crates/common/crypto/src/lib.rs | 14 ++++++--- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab8ee7a6..72117690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,7 +88,7 @@ dependencies = [ [[package]] name = "air" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "field", "koala-bear", @@ -184,7 +184,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -195,7 +195,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -662,7 +662,7 @@ dependencies = [ [[package]] name = "backend" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "air", "fiat-shamir", @@ -1943,7 +1943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2565,7 +2565,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "fiat-shamir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "field", "koala-bear", @@ -2578,7 +2578,7 @@ dependencies = [ [[package]] name = "field" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "paste", "rand 0.10.1", @@ -3647,7 +3647,7 @@ dependencies = [ [[package]] name = "koala-bear" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "field", "paste", @@ -3709,7 +3709,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lean-multisig" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "backend", "clap", @@ -3725,7 +3725,7 @@ dependencies = [ [[package]] name = "lean_compiler" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "backend", "include_dir", @@ -3739,7 +3739,7 @@ dependencies = [ [[package]] name = "lean_prover" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "backend", "lean_compiler", @@ -3754,7 +3754,7 @@ dependencies = [ [[package]] name = "lean_vm" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "backend", "tracing", @@ -4951,7 +4951,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5306,7 +5306,7 @@ dependencies = [ [[package]] name = "parallel" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "system-info", ] @@ -5501,7 +5501,7 @@ dependencies = [ [[package]] name = "poly" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "field", "koala-bear", @@ -6085,7 +6085,7 @@ dependencies = [ [[package]] name = "rec_aggregation" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "backend", "include_dir", @@ -6402,7 +6402,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6914,7 +6914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7115,7 +7115,7 @@ dependencies = [ [[package]] name = "sub_protocols" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "backend", "lean_vm", @@ -7131,7 +7131,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sumcheck" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "air", "fiat-shamir", @@ -7146,7 +7146,7 @@ dependencies = [ [[package]] name = "symetric" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "field", "koala-bear", @@ -7234,7 +7234,7 @@ dependencies = [ [[package]] name = "system-info" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "libc", ] @@ -7261,7 +7261,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7767,7 +7767,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utils" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "parallel", "serde", @@ -8031,7 +8031,7 @@ dependencies = [ [[package]] name = "whir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "fiat-shamir", "field", @@ -8085,7 +8085,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8589,7 +8589,7 @@ dependencies = [ [[package]] name = "xmss" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "backend", "ethereum_ssz", @@ -8764,7 +8764,7 @@ dependencies = [ [[package]] name = "zk-alloc" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=380da82c330eac2841252a655a86ea48ec173bb6#380da82c330eac2841252a655a86ea48ec173bb6" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" dependencies = [ "libc", "parallel", diff --git a/Cargo.toml b/Cargo.toml index 327d0fc1..0e4a59e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,8 +59,8 @@ clap = { version = "4.3", features = ["derive", "env"] } # leanVM's `main` internalized XMSS (dropping the external leanSig dependency) # and reworked the aggregation API (see "Xmss api rework"). # Pinned to a `main` commit for reproducible builds; bump the revs to track main. -xmss = { git = "https://github.com/leanEthereum/leanVM.git", rev = "380da82c330eac2841252a655a86ea48ec173bb6" } -lean-multisig = { git = "https://github.com/leanEthereum/leanVM.git", rev = "380da82c330eac2841252a655a86ea48ec173bb6" } +xmss = { git = "https://github.com/leanEthereum/leanVM.git", rev = "a5909d18647de6aed38640c098d9177fab2bf36a" } +lean-multisig = { git = "https://github.com/leanEthereum/leanVM.git", rev = "a5909d18647de6aed38640c098d9177fab2bf36a" } # SSZ codec used by leanVM's xmss pubkey/signature types (ethereum_ssz, not libssz). ssz = { package = "ethereum_ssz", version = "0.10" } diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 10cb75c1..17d665f8 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -5,9 +5,9 @@ use crate::signature::{ }; use lean_multisig::{ MultiMessageAggregateSignature as LMType2, SingleMessageAggregateSignature as LMType1, - aggregate_single_message_signatures, merge_single_message_aggregates, setup_prover, - setup_verifier, split_multi_message_aggregate, verify_multi_message_aggregate, - verify_single_message_aggregate, + aggregate_single_message_signatures, merge_single_message_aggregates, + setup_prover_without_arena, setup_verifier, split_multi_message_aggregate, + verify_multi_message_aggregate, verify_single_message_aggregate, }; use std::sync::{Mutex, MutexGuard}; use thiserror::Error; @@ -33,9 +33,13 @@ fn prover_permit() -> MutexGuard<'static, ()> { }) } -/// Engages leanVM's arena; skipping it stays correct but slow. +/// Initializes the proving backend on the system allocator. +/// +/// leanVM's `setup_prover` enables a bump arena that never releases pages, so every +/// node's RSS climbs to the arena cap regardless of how much proving it does. We trade +/// the arena's throughput for bounded memory: proving is slower, results are identical. pub fn ensure_prover_ready() { - setup_prover(); + setup_prover_without_arena(); } /// Needed before any decode, not just before verifying. From 64b109ac877190c5681080954352c784202d6eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:53:56 -0300 Subject: [PATCH 09/20] refactor(crypto): hand out prover setup and the permit together Every prover entry point paired `ensure_prover_ready()` with `prover_permit()`, and neither is useful without the other: setup is a no-op after the first call, and proving without the permit is exactly the concurrent-proof case leanVM panics on. Fold them into `acquire_prover()`, which returns the guard, so a caller cannot reach the prover half-armed. Setup now runs under the permit, which serializes the one-time bytecode compile instead of racing every waiting caller into the same `OnceLock`. The idempotency test drops each guard before taking the next: the permit is not reentrant, so holding two at once in one thread would deadlock. --- crates/common/crypto/src/lib.rs | 55 +++++++++++++++++---------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 17d665f8..4bac5312 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -21,25 +21,30 @@ pub mod shadow_cost; /// log(1/rate) for the WHIR commitment scheme used inside the aggregation prover. const LOG_INV_RATE: usize = 2; -/// leanVM allows one proof at a time per process; a second one panics. -static PROVER_PERMIT: Mutex<()> = Mutex::new(()); - +/// Claims the right to prove, initializing the backend on first use. +/// +/// Setup and the permit are handed out together because every prover entry point needs +/// both: leanVM allows one proof at a time per process, and a second concurrent one panics. +/// Proving is legal only while the returned guard is alive. +/// +/// Setup deliberately skips leanVM's `setup_prover`, which engages a bump arena that never +/// returns pages to the OS: `free` is a no-op and a phase reset only rewinds the per-thread +/// bump pointers, so a node's RSS ratchets to its high-water mark and stays there. +/// `setup_prover_without_arena` runs the same prover on the system allocator: slower, +/// bounded, identical proofs. +/// /// The permit guards no data, so a poisoned lock is recovered rather than propagated: /// one panicking prover must not brick every later proof. It is still an incident. -fn prover_permit() -> MutexGuard<'static, ()> { - PROVER_PERMIT.lock().unwrap_or_else(|poisoned| { +fn acquire_prover() -> MutexGuard<'static, ()> { + static PROVER_PERMIT: Mutex<()> = Mutex::new(()); + let permit = PROVER_PERMIT.lock().unwrap_or_else(|poisoned| { error!("a previous proving job panicked while holding the permit; continuing"); poisoned.into_inner() - }) -} - -/// Initializes the proving backend on the system allocator. -/// -/// leanVM's `setup_prover` enables a bump arena that never releases pages, so every -/// node's RSS climbs to the arena cap regardless of how much proving it does. We trade -/// the arena's throughput for bounded memory: proving is slower, results are identical. -pub fn ensure_prover_ready() { + }); + // Idempotent, and cheap after the first call. Held under the permit so the one-time + // bytecode compile runs serialized rather than racing every waiting caller into it. setup_prover_without_arena(); + permit } /// Needed before any decode, not just before verifying. @@ -199,8 +204,7 @@ pub fn aggregate_signatures( return Ok(dummy); } - ensure_prover_ready(); - let _permit = prover_permit(); + let _permit = acquire_prover(); let raw_xmss: Vec<(LeanSigPublicKey, LeanSigSignature)> = public_keys .into_iter() @@ -255,8 +259,7 @@ pub fn aggregate_mixed( return Ok(dummy); } - ensure_prover_ready(); - let _permit = prover_permit(); + let _permit = acquire_prover(); let children_native: Vec = children .into_iter() @@ -310,8 +313,7 @@ pub fn aggregate_proofs( return Ok(dummy); } - ensure_prover_ready(); - let _permit = prover_permit(); + let _permit = acquire_prover(); let children_native: Vec = children .into_iter() @@ -406,8 +408,7 @@ pub fn merge_type_1s_into_type_2( return Ok(dummy); } - ensure_prover_ready(); - let _permit = prover_permit(); + let _permit = acquire_prover(); let type_1s_native: Vec = type_1s .into_iter() @@ -501,8 +502,7 @@ pub fn split_type_2_by_message( )); } - ensure_prover_ready(); - let _permit = prover_permit(); + let _permit = acquire_prover(); let pubkeys_per_info: Vec> = pubkeys_per_component .into_iter() @@ -570,8 +570,11 @@ mod tests { // Should not panic when called multiple times. The first call compiles // the self-referential aggregation bytecode; subsequent calls are cheap // (`OnceLock::get_or_init`). - ensure_prover_ready(); - ensure_prover_ready(); + // + // The permit is dropped between acquisitions: it is not reentrant, so holding + // both at once would deadlock. That also covers release-on-drop. + drop(acquire_prover()); + drop(acquire_prover()); ensure_verifier_ready(); ensure_verifier_ready(); } From 97cbe1dab9643c46b51ceb7864a56ee4fb074c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:59:36 -0300 Subject: [PATCH 10/20] refactor(crypto): hold the prover permit only across the prove call The permit was taken at the top of each entry point, so a caller sat on the process-wide prover lock while it converted pubkeys, zipped signatures and postcard-decoded child proofs: work that touches no prover state and that grows with the number of aggregated signatures. Take it immediately before the aggregate/merge/split call instead. Decoding does need one thing setup provided: a Type-2 decode rebuilds the bytecode claim, and without the aggregation bytecode it returns `None`, which surfaces as a bogus `DeserializationFailed`. The decoding paths now call `ensure_verifier_ready()`, its documented prerequisite, which is a `OnceLock` init rather than a lock. --- crates/common/crypto/src/lib.rs | 37 ++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 4bac5312..dff662dd 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -27,6 +27,11 @@ const LOG_INV_RATE: usize = 2; /// both: leanVM allows one proof at a time per process, and a second concurrent one panics. /// Proving is legal only while the returned guard is alive. /// +/// Call this immediately before the prove call, not at the top of the function: decoding +/// and argument conversion need no permit, and holding it across them serializes callers +/// for no reason. Decoding does need the aggregation bytecode, which is what +/// [`ensure_verifier_ready`] is for. +/// /// Setup deliberately skips leanVM's `setup_prover`, which engages a bump arena that never /// returns pages to the OS: `free` is a no-op and a phase reset only rewinds the per-thread /// bump pointers, so a node's RSS ratchets to its high-water mark and stays there. @@ -41,8 +46,7 @@ fn acquire_prover() -> MutexGuard<'static, ()> { error!("a previous proving job panicked while holding the permit; continuing"); poisoned.into_inner() }); - // Idempotent, and cheap after the first call. Held under the permit so the one-time - // bytecode compile runs serialized rather than racing every waiting caller into it. + // Idempotent, and cheap after the first call (each step is `Once`/`OnceLock` guarded). setup_prover_without_arena(); permit } @@ -204,14 +208,14 @@ pub fn aggregate_signatures( return Ok(dummy); } - let _permit = acquire_prover(); - let raw_xmss: Vec<(LeanSigPublicKey, LeanSigSignature)> = public_keys .into_iter() .zip(signatures) .map(|(pk, sig)| (pk.into_inner(), sig.into_inner())) .collect(); + let _permit = acquire_prover(); + let proof = aggregate_single_message_signatures(&[], raw_xmss, message.0, slot, LOG_INV_RATE) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; @@ -259,7 +263,9 @@ pub fn aggregate_mixed( return Ok(dummy); } - let _permit = acquire_prover(); + // Decoding needs the aggregation bytecode, but not the permit: no proving + // happens until the aggregate call below. + ensure_verifier_ready(); let children_native: Vec = children .into_iter() @@ -273,6 +279,8 @@ pub fn aggregate_mixed( .map(|(pk, sig)| (pk.into_inner(), sig.into_inner())) .collect(); + let _permit = acquire_prover(); + let proof = aggregate_single_message_signatures( &children_native, raw_xmss, @@ -313,7 +321,9 @@ pub fn aggregate_proofs( return Ok(dummy); } - let _permit = acquire_prover(); + // Decoding needs the aggregation bytecode, but not the permit: no proving + // happens until the aggregate call below. + ensure_verifier_ready(); let children_native: Vec = children .into_iter() @@ -321,6 +331,8 @@ pub fn aggregate_proofs( .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) .collect::>()?; + let _permit = acquire_prover(); + let proof = aggregate_single_message_signatures( &children_native, vec![], @@ -408,7 +420,9 @@ pub fn merge_type_1s_into_type_2( return Ok(dummy); } - let _permit = acquire_prover(); + // Decoding needs the aggregation bytecode, but not the permit: no proving + // happens until the merge call below. + ensure_verifier_ready(); let type_1s_native: Vec = type_1s .into_iter() @@ -416,6 +430,8 @@ pub fn merge_type_1s_into_type_2( .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) .collect::>()?; + let _permit = acquire_prover(); + let merged = merge_single_message_aggregates(type_1s_native, LOG_INV_RATE) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; @@ -502,7 +518,10 @@ pub fn split_type_2_by_message( )); } - let _permit = acquire_prover(); + // Type-2 decode rebuilds the bytecode claim, so it needs the aggregation bytecode: + // without it the decode returns `None` and looks like a corrupt proof. It does not + // need the permit, though; no proving happens until the split call below. + ensure_verifier_ready(); let pubkeys_per_info: Vec> = pubkeys_per_component .into_iter() @@ -524,6 +543,8 @@ pub fn split_type_2_by_message( _ => return Err(AggregationError::MultipleMessages), }; + let _permit = acquire_prover(); + let component = split_multi_message_aggregate(type_2, index, LOG_INV_RATE) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; From 80930d25e97eae3100cb50de93319da77d01db22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:02:48 -0300 Subject: [PATCH 11/20] refactor(crypto): let the decoding paths acquire the prover up front The previous commit had the decoding entry points call `ensure_verifier_ready()` and then `acquire_prover()`, which is the two-call pattern the merge was meant to remove: it splits one setup across two names and invites a caller to assume decoding is verifier-only work. Since those paths need setup before they can decode, they take the permit from the start and let it cover the decode. `aggregate_signatures` keeps the narrow scope: it only reshapes its inputs and never decodes, so nothing there needs setup. --- crates/common/crypto/src/lib.rs | 43 ++++++++++++++------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index dff662dd..305df550 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -27,10 +27,11 @@ const LOG_INV_RATE: usize = 2; /// both: leanVM allows one proof at a time per process, and a second concurrent one panics. /// Proving is legal only while the returned guard is alive. /// -/// Call this immediately before the prove call, not at the top of the function: decoding -/// and argument conversion need no permit, and holding it across them serializes callers -/// for no reason. Decoding does need the aggregation bytecode, which is what -/// [`ensure_verifier_ready`] is for. +/// Take it as late as the setup allows. Callers that only reshape their inputs claim it +/// right before the prove call, since conversion touches no prover state. Callers that +/// decode a stored proof claim it up front instead: decoding needs the aggregation +/// bytecode that setup installs, and a Type-2 decode without it returns `None`, which +/// surfaces as a bogus `DeserializationFailed`. /// /// Setup deliberately skips leanVM's `setup_prover`, which engages a bump arena that never /// returns pages to the OS: `free` is a no-op and a phase reset only rewinds the per-thread @@ -263,9 +264,9 @@ pub fn aggregate_mixed( return Ok(dummy); } - // Decoding needs the aggregation bytecode, but not the permit: no proving - // happens until the aggregate call below. - ensure_verifier_ready(); + // Held from here rather than from the aggregate call: decoding the children needs + // the aggregation bytecode that `acquire_prover` installs. + let _permit = acquire_prover(); let children_native: Vec = children .into_iter() @@ -279,8 +280,6 @@ pub fn aggregate_mixed( .map(|(pk, sig)| (pk.into_inner(), sig.into_inner())) .collect(); - let _permit = acquire_prover(); - let proof = aggregate_single_message_signatures( &children_native, raw_xmss, @@ -321,9 +320,9 @@ pub fn aggregate_proofs( return Ok(dummy); } - // Decoding needs the aggregation bytecode, but not the permit: no proving - // happens until the aggregate call below. - ensure_verifier_ready(); + // Held from here rather than from the aggregate call: decoding the children needs + // the aggregation bytecode that `acquire_prover` installs. + let _permit = acquire_prover(); let children_native: Vec = children .into_iter() @@ -331,8 +330,6 @@ pub fn aggregate_proofs( .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) .collect::>()?; - let _permit = acquire_prover(); - let proof = aggregate_single_message_signatures( &children_native, vec![], @@ -420,9 +417,9 @@ pub fn merge_type_1s_into_type_2( return Ok(dummy); } - // Decoding needs the aggregation bytecode, but not the permit: no proving - // happens until the merge call below. - ensure_verifier_ready(); + // Held from here rather than from the merge call: decoding the inputs needs + // the aggregation bytecode that `acquire_prover` installs. + let _permit = acquire_prover(); let type_1s_native: Vec = type_1s .into_iter() @@ -430,8 +427,6 @@ pub fn merge_type_1s_into_type_2( .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) .collect::>()?; - let _permit = acquire_prover(); - let merged = merge_single_message_aggregates(type_1s_native, LOG_INV_RATE) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; @@ -518,10 +513,10 @@ pub fn split_type_2_by_message( )); } - // Type-2 decode rebuilds the bytecode claim, so it needs the aggregation bytecode: - // without it the decode returns `None` and looks like a corrupt proof. It does not - // need the permit, though; no proving happens until the split call below. - ensure_verifier_ready(); + // Held from here rather than from the split call: the Type-2 decode below rebuilds + // the bytecode claim, so it needs the aggregation bytecode that `acquire_prover` + // installs. Without it the decode returns `None` and looks like a corrupt proof. + let _permit = acquire_prover(); let pubkeys_per_info: Vec> = pubkeys_per_component .into_iter() @@ -543,8 +538,6 @@ pub fn split_type_2_by_message( _ => return Err(AggregationError::MultipleMessages), }; - let _permit = acquire_prover(); - let component = split_multi_message_aggregate(type_2, index, LOG_INV_RATE) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; From 3760b9de3a5b5f9af744e0f45c0676fb061bbcf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:39:05 -0300 Subject: [PATCH 12/20] feat(crypto): make the prover arena an opt-in The system allocator is the right default (bounded RSS), but it costs proving throughput, and a host with memory to spare should be able to buy that back. A `OnceLock` carries the choice: `enable_prover_arena()` sets it, and the first `acquire_prover()` latches it via `get_or_init(|| false)`, so the allocator cannot change under a prover that already ran. The setter reports whether it won the latch instead of failing silently, since being called too late is the likely mistake and it is invisible otherwise. Covered by an integration test rather than a lib test: the latch is process-wide, so enabling the arena in the lib binary would change the allocator under every other test and make the result ordering-dependent. --- crates/common/crypto/src/lib.rs | 42 ++++++++++++--------- crates/common/crypto/tests/arena.rs | 58 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 crates/common/crypto/tests/arena.rs diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 305df550..ecc94930 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -5,11 +5,11 @@ use crate::signature::{ }; use lean_multisig::{ MultiMessageAggregateSignature as LMType2, SingleMessageAggregateSignature as LMType1, - aggregate_single_message_signatures, merge_single_message_aggregates, + aggregate_single_message_signatures, merge_single_message_aggregates, setup_prover, setup_prover_without_arena, setup_verifier, split_multi_message_aggregate, verify_multi_message_aggregate, verify_single_message_aggregate, }; -use std::sync::{Mutex, MutexGuard}; +use std::sync::{Mutex, MutexGuard, OnceLock}; use thiserror::Error; use tracing::error; @@ -21,26 +21,27 @@ pub mod shadow_cost; /// log(1/rate) for the WHIR commitment scheme used inside the aggregation prover. const LOG_INV_RATE: usize = 2; +/// Whether to prove on leanVM's arena. Latched by the first [`acquire_prover`]. +static USE_ARENA: OnceLock = OnceLock::new(); + +/// Opts the prover into leanVM's bump arena: faster proving, unbounded RSS. +/// +/// The arena never returns pages to the OS, so a node's memory ratchets to its +/// allocation high-water mark and stays there. Off by default; worth it only where +/// memory is plentiful and proving latency matters. +/// +/// Must be called before the first prove. Returns `false` if the choice was already +/// latched, meaning the call had no effect. +#[must_use = "the arena is not enabled if the choice was already latched"] +pub fn enable_prover_arena() -> bool { + USE_ARENA.set(true).is_ok() +} + /// Claims the right to prove, initializing the backend on first use. /// /// Setup and the permit are handed out together because every prover entry point needs /// both: leanVM allows one proof at a time per process, and a second concurrent one panics. /// Proving is legal only while the returned guard is alive. -/// -/// Take it as late as the setup allows. Callers that only reshape their inputs claim it -/// right before the prove call, since conversion touches no prover state. Callers that -/// decode a stored proof claim it up front instead: decoding needs the aggregation -/// bytecode that setup installs, and a Type-2 decode without it returns `None`, which -/// surfaces as a bogus `DeserializationFailed`. -/// -/// Setup deliberately skips leanVM's `setup_prover`, which engages a bump arena that never -/// returns pages to the OS: `free` is a no-op and a phase reset only rewinds the per-thread -/// bump pointers, so a node's RSS ratchets to its high-water mark and stays there. -/// `setup_prover_without_arena` runs the same prover on the system allocator: slower, -/// bounded, identical proofs. -/// -/// The permit guards no data, so a poisoned lock is recovered rather than propagated: -/// one panicking prover must not brick every later proof. It is still an incident. fn acquire_prover() -> MutexGuard<'static, ()> { static PROVER_PERMIT: Mutex<()> = Mutex::new(()); let permit = PROVER_PERMIT.lock().unwrap_or_else(|poisoned| { @@ -48,7 +49,12 @@ fn acquire_prover() -> MutexGuard<'static, ()> { poisoned.into_inner() }); // Idempotent, and cheap after the first call (each step is `Once`/`OnceLock` guarded). - setup_prover_without_arena(); + // This also latches the allocator choice: `enable_prover_arena` is a no-op afterwards. + if *USE_ARENA.get_or_init(|| false) { + setup_prover(); + } else { + setup_prover_without_arena(); + } permit } diff --git a/crates/common/crypto/tests/arena.rs b/crates/common/crypto/tests/arena.rs new file mode 100644 index 00000000..eca96bc5 --- /dev/null +++ b/crates/common/crypto/tests/arena.rs @@ -0,0 +1,58 @@ +//! Coverage for the arena opt-in. +//! +//! The choice latches process-wide on the first prove, so this cannot live in the lib +//! test binary: enabling the arena there would change the allocator under every other +//! test, and whether the call won the latch would depend on test ordering. An +//! integration test gets its own process. + +use ethlambda_crypto::{ + aggregate_signatures, enable_prover_arena, + signature::{ValidatorPublicKey, ValidatorSignature}, + verify_aggregated_signature, +}; +use ethlambda_types::primitives::H256; +use lean_multisig::{xmss_key_gen_from_seed, xmss_sign}; +use ssz::Encode as _; + +/// Mirrors the lib tests' helper: a small active range keeps key generation fast. +fn keypair_and_signature( + seed: u64, + activation_epoch: u32, + signing_epoch: u32, + message: &H256, +) -> (ValidatorPublicKey, ValidatorSignature) { + let mut seed_bytes = [0u8; 32]; + seed_bytes[..8].copy_from_slice(&seed.to_le_bytes()); + + let num_active_slots = 64u64; + let (pk, sk) = xmss_key_gen_from_seed(seed_bytes, activation_epoch as u64, num_active_slots) + .expect("valid activation range"); + let sig = xmss_sign(&sk, signing_epoch, &message.0).expect("sign"); + + ( + ValidatorPublicKey::from_bytes(&pk.as_ssz_bytes()).unwrap(), + ValidatorSignature::from_bytes(&sig.as_ssz_bytes()).unwrap(), + ) +} + +#[test] +#[ignore = "too slow"] +fn aggregates_on_the_arena_when_enabled() { + assert!( + enable_prover_arena(), + "nothing has proved yet, so the choice must still be open" + ); + assert!( + !enable_prover_arena(), + "the choice is latched once set, so a second call reports no effect" + ); + + let message = H256::from([7u8; 32]); + let slot = 10u32; + let (pk, sig) = keypair_and_signature(1, 5, slot, &message); + + // Proves on the arena: the same round trip the lib tests run on the system allocator. + let proof = aggregate_signatures(vec![pk.clone()], vec![sig], &message, slot) + .expect("aggregation on the arena"); + verify_aggregated_signature(&proof, vec![pk], &message, slot).expect("verification"); +} From 31817c8d9f4d56d13ca2da98dfeacd5f89b2c2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:43:17 -0300 Subject: [PATCH 13/20] feat(cli): add --prover-arena to opt into the leanVM arena Gives operators the memory/throughput trade at boot: off keeps RSS bounded on the system allocator, on buys proving speed with memory that is never returned to the OS. Applied immediately after parsing, ahead of every other init, since the allocator choice latches on the first proof. The too-late branch warns rather than passing silently; it should be unreachable from here, but a future init that proves earlier would otherwise flip the node to the wrong allocator with no trace. --- bin/ethlambda/src/cli.rs | 11 +++++++++++ bin/ethlambda/src/main.rs | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/bin/ethlambda/src/cli.rs b/bin/ethlambda/src/cli.rs index 4bbe1683..61b58d3d 100644 --- a/bin/ethlambda/src/cli.rs +++ b/bin/ethlambda/src/cli.rs @@ -97,6 +97,17 @@ pub(crate) struct CliOptions { /// coverage. #[arg(long, default_value = "false")] pub(crate) enable_proposer_aggregation: bool, + /// Prove on leanVM's bump arena instead of the system allocator. + /// + /// Buys proving throughput with memory: the arena never returns pages to + /// the OS, so RSS ratchets up to the process's allocation high-water mark + /// and stays there for the lifetime of the node. Off by default so a + /// long-lived node keeps bounded memory; worth enabling on hosts with + /// memory to spare where proving latency is the constraint. + /// + /// Read once at startup: the allocator is fixed before the first proof. + #[arg(long, default_value = "false")] + pub(crate) prover_arena: bool, /// Maximum number of distinct attestations to pack when building a block. /// /// Bounds how many distinct `AttestationData` entries the proposer includes diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 091c715e..1060dc45 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -85,6 +85,15 @@ async fn main() -> eyre::Result<()> { #[cfg(feature = "shadow-integration")] init_shadow_cost(&options.shadow); + // Before anything can prove: the allocator choice latches on the first proof. + if options.prover_arena { + if ethlambda_crypto::enable_prover_arena() { + info!("Proving on leanVM's arena; memory grows to the high-water mark"); + } else { + warn!("--prover-arena had no effect: something proved before startup finished"); + } + } + // Initialize metrics ethlambda_blockchain::metrics::init(); ethlambda_blockchain::metrics::set_node_info("ethlambda", version::CLIENT_VERSION); From e2a66a84ac282c6282d22bb4ce64df742889c167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:08:58 -0300 Subject: [PATCH 14/20] refactor(crypto): initialize leanVM once at startup instead of lazily Setup was reached through `acquire_prover`, which coupled two unrelated things: a caller wanting the prove lock also triggered backend init, so the decoding paths had to hold the lock across work that needs no lock purely to get the bytecode compiled first. `init_leanvm(use_arena)` now does both halves in one place and binaries call it right after parsing arguments. `acquire_prover` is just the permit again, so every entry point takes it immediately before proving, and `ensure_verifier_ready` is gone: nothing needs a lazy hook anymore. The arena `OnceLock` goes too, since the choice is a parameter rather than latched global state. Ahead of the Hive test-driver branch in main, which verifies signatures. Tests that touch a proof call `init_leanvm` themselves, standing in for the startup call. This moves ~2.3s of bytecode compilation from the first proof or verification into startup. Nodes paid it either way; it is now off the first duty's critical path, at the cost of a slower boot for nodes that never prove. --- bin/ethlambda/src/main.rs | 15 ++- .../blockchain/tests/signature_spectests.rs | 4 + crates/common/crypto/src/lib.rs | 116 +++++++++--------- crates/common/crypto/tests/arena.rs | 18 +-- 4 files changed, 77 insertions(+), 76 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 1060dc45..943308c9 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -85,14 +85,13 @@ async fn main() -> eyre::Result<()> { #[cfg(feature = "shadow-integration")] init_shadow_cost(&options.shadow); - // Before anything can prove: the allocator choice latches on the first proof. - if options.prover_arena { - if ethlambda_crypto::enable_prover_arena() { - info!("Proving on leanVM's arena; memory grows to the high-water mark"); - } else { - warn!("--prover-arena had no effect: something proved before startup finished"); - } - } + // Compiles the aggregation bytecode and fixes the prover's allocator. Ahead of the + // test-driver branch below, which verifies signatures, and of every consensus path. + info!( + arena = options.prover_arena, + "Initializing leanVM prover and verifier" + ); + ethlambda_crypto::init_leanvm(options.prover_arena); // Initialize metrics ethlambda_blockchain::metrics::init(); diff --git a/crates/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs index a11a58cc..d45f7c62 100644 --- a/crates/blockchain/tests/signature_spectests.rs +++ b/crates/blockchain/tests/signature_spectests.rs @@ -20,6 +20,10 @@ const SUPPORTED_FIXTURE_FORMAT: &str = "verify_signatures_test"; const SKIP_TESTS: &[&str] = &[]; fn run(path: &Path) -> datatest_stable::Result<()> { + // These fixtures verify real signatures, so they need the backend a binary would set + // up at startup. Idempotent, so calling it per fixture costs nothing after the first. + ethlambda_crypto::init_leanvm(false); + let tests = VerifySignaturesTestVector::from_file(path)?; for (name, test) in tests.tests { diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index ecc94930..fbcc0cc3 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -1,3 +1,14 @@ +//! XMSS signature aggregation and verification, wrapping leanVM. +//! +//! [`init_leanvm`] must be called once at startup, before anything else here: it compiles +//! the aggregation bytecode and fixes the prover's allocator. Proving panics without it, +//! and decoding a stored proof misreports as a corrupt proof. Binaries call it straight +//! after argument parsing; tests that touch a proof call it themselves. +//! +//! Everything else assumes it has run. Aggregation produces Type-1 proofs (one message, +//! one slot) and Type-2 proofs (several messages merged); both travel without their +//! participant pubkeys, which a receiver rebuilds from its own validator registry. + use ethlambda_types::{block::ByteList512KiB, primitives::H256}; use crate::signature::{ @@ -9,7 +20,7 @@ use lean_multisig::{ setup_prover_without_arena, setup_verifier, split_multi_message_aggregate, verify_multi_message_aggregate, verify_single_message_aggregate, }; -use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::sync::{Mutex, MutexGuard}; use thiserror::Error; use tracing::error; @@ -21,46 +32,41 @@ pub mod shadow_cost; /// log(1/rate) for the WHIR commitment scheme used inside the aggregation prover. const LOG_INV_RATE: usize = 2; -/// Whether to prove on leanVM's arena. Latched by the first [`acquire_prover`]. -static USE_ARENA: OnceLock = OnceLock::new(); - -/// Opts the prover into leanVM's bump arena: faster proving, unbounded RSS. +/// Initializes the leanVM backend. Call once at startup, before any other function here. /// -/// The arena never returns pages to the OS, so a node's memory ratchets to its -/// allocation high-water mark and stays there. Off by default; worth it only where -/// memory is plentiful and proving latency matters. +/// Everything downstream assumes this has run: proving panics without the aggregation +/// bytecode, and decoding a stored proof needs it too (a Type-2 decode rebuilds the +/// bytecode claim and returns `None` without it, which surfaces as a bogus +/// `DeserializationFailed`). Doing it up front keeps the cost off the first duty. /// -/// Must be called before the first prove. Returns `false` if the choice was already -/// latched, meaning the call had no effect. -#[must_use = "the arena is not enabled if the choice was already latched"] -pub fn enable_prover_arena() -> bool { - USE_ARENA.set(true).is_ok() -} - -/// Claims the right to prove, initializing the backend on first use. +/// `use_arena` picks the prover's allocator. leanVM's arena is faster but never returns +/// pages to the OS, so a node's RSS ratchets to its allocation high-water mark and stays +/// there; the system allocator trades throughput for bounded memory. /// -/// Setup and the permit are handed out together because every prover entry point needs -/// both: leanVM allows one proof at a time per process, and a second concurrent one panics. -/// Proving is legal only while the returned guard is alive. -fn acquire_prover() -> MutexGuard<'static, ()> { - static PROVER_PERMIT: Mutex<()> = Mutex::new(()); - let permit = PROVER_PERMIT.lock().unwrap_or_else(|poisoned| { - error!("a previous proving job panicked while holding the permit; continuing"); - poisoned.into_inner() - }); - // Idempotent, and cheap after the first call (each step is `Once`/`OnceLock` guarded). - // This also latches the allocator choice: `enable_prover_arena` is a no-op afterwards. - if *USE_ARENA.get_or_init(|| false) { +/// Idempotent: every step is `Once`/`OnceLock` guarded. +pub fn init_leanvm(use_arena: bool) { + if use_arena { setup_prover(); } else { setup_prover_without_arena(); } - permit + setup_verifier(); } -/// Needed before any decode, not just before verifying. -pub fn ensure_verifier_ready() { - setup_verifier(); +/// Claims the exclusive right to prove. +/// +/// leanVM allows one proof at a time per process; a second concurrent one panics. +/// Proving is legal only while the returned guard is alive, so take it immediately +/// before the prove call: decoding and argument conversion need no permit. +/// +/// The permit guards no data, so a poisoned lock is recovered rather than propagated: +/// one panicking prover must not brick every later proof. It is still an incident. +fn acquire_prover() -> MutexGuard<'static, ()> { + static PROVER_PERMIT: Mutex<()> = Mutex::new(()); + PROVER_PERMIT.lock().unwrap_or_else(|poisoned| { + error!("a previous proving job panicked while holding the permit; continuing"); + poisoned.into_inner() + }) } /// Error type for signature aggregation operations. @@ -270,10 +276,6 @@ pub fn aggregate_mixed( return Ok(dummy); } - // Held from here rather than from the aggregate call: decoding the children needs - // the aggregation bytecode that `acquire_prover` installs. - let _permit = acquire_prover(); - let children_native: Vec = children .into_iter() .enumerate() @@ -286,6 +288,8 @@ pub fn aggregate_mixed( .map(|(pk, sig)| (pk.into_inner(), sig.into_inner())) .collect(); + let _permit = acquire_prover(); + let proof = aggregate_single_message_signatures( &children_native, raw_xmss, @@ -326,16 +330,14 @@ pub fn aggregate_proofs( return Ok(dummy); } - // Held from here rather than from the aggregate call: decoding the children needs - // the aggregation bytecode that `acquire_prover` installs. - let _permit = acquire_prover(); - let children_native: Vec = children .into_iter() .enumerate() .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) .collect::>()?; + let _permit = acquire_prover(); + let proof = aggregate_single_message_signatures( &children_native, vec![], @@ -369,7 +371,6 @@ pub fn verify_aggregated_signature( crate::shadow_cost::sleep(crate::shadow_cost::verify_delay(verify_n)); return Ok(()); } - ensure_verifier_ready(); let lean_pubkeys = into_lean_pubkeys(public_keys); let sig = LMType1::from_bytes_without_pubkeys(proof_data.iter().as_slice(), lean_pubkeys) @@ -423,16 +424,14 @@ pub fn merge_type_1s_into_type_2( return Ok(dummy); } - // Held from here rather than from the merge call: decoding the inputs needs - // the aggregation bytecode that `acquire_prover` installs. - let _permit = acquire_prover(); - let type_1s_native: Vec = type_1s .into_iter() .enumerate() .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) .collect::>()?; + let _permit = acquire_prover(); + let merged = merge_single_message_aggregates(type_1s_native, LOG_INV_RATE) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; @@ -462,8 +461,6 @@ pub fn verify_type_2_signature( return Ok(()); } - ensure_verifier_ready(); - let pubkeys_per_info: Vec> = pubkeys_per_component .into_iter() .map(into_lean_pubkeys) @@ -519,11 +516,6 @@ pub fn split_type_2_by_message( )); } - // Held from here rather than from the split call: the Type-2 decode below rebuilds - // the bytecode claim, so it needs the aggregation bytecode that `acquire_prover` - // installs. Without it the decode returns `None` and looks like a corrupt proof. - let _permit = acquire_prover(); - let pubkeys_per_info: Vec> = pubkeys_per_component .into_iter() .map(into_lean_pubkeys) @@ -544,6 +536,8 @@ pub fn split_type_2_by_message( _ => return Err(AggregationError::MultipleMessages), }; + let _permit = acquire_prover(); + let component = split_multi_message_aggregate(type_2, index, LOG_INV_RATE) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; @@ -584,24 +578,31 @@ mod tests { (validator_pk, validator_sig) } + /// Stands in for the startup call every binary makes. Without it the prover panics + /// on the missing aggregation bytecode. + fn init() { + init_leanvm(false); + } + #[test] #[ignore = "slow: compiles the leanVM aggregation bytecode (needs a release-sized stack)"] fn test_setup_is_idempotent() { // Should not panic when called multiple times. The first call compiles // the self-referential aggregation bytecode; subsequent calls are cheap // (`OnceLock::get_or_init`). - // + init_leanvm(false); + init_leanvm(false); + // The permit is dropped between acquisitions: it is not reentrant, so holding // both at once would deadlock. That also covers release-on-drop. drop(acquire_prover()); drop(acquire_prover()); - ensure_verifier_ready(); - ensure_verifier_ready(); } #[test] #[ignore = "too slow"] fn test_aggregate_single_signature() { + init(); let message = H256::from([42u8; 32]); let slot = 10u32; let activation_epoch = 5u32; @@ -626,6 +627,7 @@ mod tests { #[test] #[ignore = "too slow"] fn test_aggregate_multiple_signatures() { + init(); let message = H256::from([42u8; 32]); let slot = 15u32; @@ -662,6 +664,7 @@ mod tests { #[test] #[ignore = "too slow"] fn test_verify_wrong_message_fails() { + init(); let message = H256::from([42u8; 32]); let wrong_message = H256::from([43u8; 32]); let slot = 10u32; @@ -683,6 +686,7 @@ mod tests { #[test] #[ignore = "too slow"] fn test_verify_wrong_slot_fails() { + init(); let message = H256::from([42u8; 32]); let slot = 10u32; let wrong_slot = 11u32; @@ -708,6 +712,7 @@ mod tests { #[test] #[ignore = "too slow"] fn test_verify_wrong_pubkey_set_fails() { + init(); let message = H256::from([42u8; 32]); let slot = 10u32; @@ -730,6 +735,7 @@ mod tests { #[test] #[ignore = "too slow"] fn test_type_2_merge_verify_split_round_trip() { + init(); let msg_a = H256::from([0x11u8; 32]); let msg_b = H256::from([0x22u8; 32]); let slot_a: u32 = 7; diff --git a/crates/common/crypto/tests/arena.rs b/crates/common/crypto/tests/arena.rs index eca96bc5..1655b71d 100644 --- a/crates/common/crypto/tests/arena.rs +++ b/crates/common/crypto/tests/arena.rs @@ -1,12 +1,11 @@ -//! Coverage for the arena opt-in. +//! Coverage for the arena path of [`init_leanvm`]. //! -//! The choice latches process-wide on the first prove, so this cannot live in the lib -//! test binary: enabling the arena there would change the allocator under every other -//! test, and whether the call won the latch would depend on test ordering. An +//! leanVM's arena engages process-wide and cannot be disengaged, so this cannot live in +//! the lib test binary: it would change the allocator under every other test. An //! integration test gets its own process. use ethlambda_crypto::{ - aggregate_signatures, enable_prover_arena, + aggregate_signatures, init_leanvm, signature::{ValidatorPublicKey, ValidatorSignature}, verify_aggregated_signature, }; @@ -38,14 +37,7 @@ fn keypair_and_signature( #[test] #[ignore = "too slow"] fn aggregates_on_the_arena_when_enabled() { - assert!( - enable_prover_arena(), - "nothing has proved yet, so the choice must still be open" - ); - assert!( - !enable_prover_arena(), - "the choice is latched once set, so a second call reports no effect" - ); + init_leanvm(true); let message = H256::from([7u8; 32]); let slot = 10u32; From 8f6a6b6d5edbf3ca80cb61d2f46e449ef5c8dff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:06:22 -0300 Subject: [PATCH 15/20] fix(tests): size placeholder pubkeys from PUBLIC_KEY_SIZE The test helpers main brought in (#547, #554) build validators with hardcoded 52-byte pubkeys, which no longer compile on this branch: tracking leanVM main shrinks XMSS pubkeys to 32 bytes. Reference the constant so the size follows the type. --- bin/ethlambda/src/main.rs | 5 +++-- crates/blockchain/src/store.rs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index b11923d3..c935aaf9 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -772,6 +772,7 @@ mod tests { use super::*; use ethlambda_storage::backend::InMemoryBackend; use ethlambda_types::genesis::GenesisValidatorEntry; + use ethlambda_types::state::PUBLIC_KEY_SIZE; /// Validator-config snippet matching `lean-quickstart`'s ansible-devnet /// where networks share a non-default committee count. @@ -897,8 +898,8 @@ validators: GenesisConfig { genesis_time, genesis_validators: vec![GenesisValidatorEntry { - attestation_pubkey: [1u8; 52], - proposal_pubkey: [2u8; 52], + attestation_pubkey: [1u8; PUBLIC_KEY_SIZE], + proposal_pubkey: [2u8; PUBLIC_KEY_SIZE], }], } } diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 67d4d4af..ea5f6a35 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -1839,8 +1839,8 @@ mod tests { fn make_validators(count: u64) -> Vec { (0..count) .map(|index| ethlambda_types::state::Validator { - attestation_pubkey: [0u8; 52], - proposal_pubkey: [0u8; 52], + attestation_pubkey: ethlambda_types::state::ValidatorPubkeyBytes::default(), + proposal_pubkey: ethlambda_types::state::ValidatorPubkeyBytes::default(), index, }) .collect() From 691df396c124ee40d5f867f3b5881674fc352e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:59:34 -0300 Subject: [PATCH 16/20] fix(tests): size the merged placeholder pubkeys from PUBLIC_KEY_SIZE The latest merge from main brought in two more test helpers (#554, #556) that build validators with hardcoded 52-byte pubkeys, which do not compile on this branch: tracking leanVM main shrinks XMSS pubkeys to 32 bytes. Reference the constant so the size follows the type, same as 8f6a6b6 did for the helpers the previous merge carried over. --- bin/ethlambda/src/main.rs | 2 +- crates/storage/src/store.rs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index a2a9f1a1..edc6a101 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -1028,7 +1028,7 @@ validators: seed_db(backend.clone(), &seeded_genesis); let mut other_genesis = test_genesis(genesis_time); - other_genesis.genesis_validators[0].attestation_pubkey = [9u8; 52]; + other_genesis.genesis_validators[0].attestation_pubkey = [9u8; PUBLIC_KEY_SIZE]; let Err(err) = fetch_initial_state(&[], &other_genesis, backend).await else { panic!("a foreign validator set must not be silently re-anchored"); }; diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 6f18f904..6b1e0223 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -1732,13 +1732,14 @@ mod tests { use super::*; use crate::backend::InMemoryBackend; use ethlambda_types::genesis::{GenesisMismatch, GenesisValidatorEntry}; + use ethlambda_types::state::PUBLIC_KEY_SIZE; /// Validator at `index` whose two pubkeys are filled with `seed`, so /// changing the seed changes the registry without changing its size. fn validator(index: u64, seed: u8) -> Validator { Validator { - attestation_pubkey: [seed; 52], - proposal_pubkey: [seed.wrapping_add(1); 52], + attestation_pubkey: [seed; PUBLIC_KEY_SIZE], + proposal_pubkey: [seed.wrapping_add(1); PUBLIC_KEY_SIZE], index, } } From 5d9f1363ac01610c1dec9b1fa0c2be2433e9c7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:33:46 -0300 Subject: [PATCH 17/20] fix(bin): repair the benchmark corpus and stale crypto references after the main merge Merging main brought in the offline block-building benchmark (#595, #596), which was written against the old 52-byte pubkey. It is not a textual conflict, so the merge landed silently and only broke at compile time: error[E0308]: mismatched types --> bin/ethlambda/src/benchmark/corpus.rs:127:5 | expected an array with a size of 32, found one with a size of 52 `synthetic_pubkey` now sizes its buffer from `PUBLIC_KEY_SIZE`, so the next scheme change moves it rather than breaking the build again. The same merge left three references to a dependency this branch removed: - The benchmark report embedded a resolved `leansig` revision read from Cargo.lock. leanVM internalized XMSS, so no `leansig` package resolves any more and every report would have printed `leansig=unknown`. The header now carries the single leanVM revision that pins the whole signature stack. - `rand` was a dependency of `ethlambda-crypto` and a dev-dependency of `ethlambda-blockchain` and `ethlambda-storage` only for leanSig keygen in tests this branch rewrote. Dropped from all three; `ethlambda-types` keeps its own, which was already unused before this branch. - CLAUDE.md still described 52-byte keys, 2536-byte signatures, and a `leansig` dependency. Also corrects the `SingleMessageAggregate` / `MultiMessageAggregate` doc comments, which claimed the proof bytes are leanVM's `to_bytes()` form with participant pubkeys embedded. Every producer and consumer uses `to_bytes_without_pubkeys()`: pubkeys stay off the wire and the verifier rebuilds the set from the aggregation bits, which is the property that makes the framing match main. Fixture-driven spec tests still fail (122 forkchoice, 73 stf, 7 ssz, 3 signature), all at deserialization: leanSpec's latest released fixtures are still on the 52-byte scheme. Every other test in the workspace passes. --- CLAUDE.md | 17 +++++++++---- Cargo.lock | 3 --- bin/ethlambda/build.rs | 35 ++++++++++----------------- bin/ethlambda/src/benchmark/corpus.rs | 4 +-- bin/ethlambda/src/benchmark/report.rs | 18 ++++---------- crates/blockchain/Cargo.toml | 1 - crates/common/crypto/Cargo.toml | 1 - crates/common/types/src/block.rs | 22 ++++++++++------- crates/storage/Cargo.toml | 1 - docs/benchmarking.md | 9 +++---- 10 files changed, 49 insertions(+), 62 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4b14c91..d37ad8e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ crates/ └─ src/metrics.rs # State transition timing + counters common/ ├─ types/ # Core types (State, Block, Attestation, Checkpoint) - ├─ crypto/ # XMSS aggregation (leansig wrapper) + ├─ crypto/ # XMSS sign/verify + aggregation (leanVM wrapper) ├─ metrics/ # Prometheus re-exports, TimingGuard, gather utilities └─ test-fixtures/ # Spec-fixture loading (prod dep of rpc's Hive test driver) net/ @@ -262,9 +262,15 @@ actual_slot = finalized_slot + 1 + relative_index **XMSS (eXtended Merkle Signature Scheme):** - Post-quantum signature scheme -- 52-byte public keys, 2536-byte signatures (`SIGNATURE_SIZE` in `common/types/src/signature.rs`) +- Wire sizes: `PUBLIC_KEY_SIZE` (`common/types/src/state.rs`) and `SIGNATURE_SIZE` + (`common/types/src/attestation.rs`), static-asserted against leanVM's scheme + constants in `common/crypto/src/signature.rs` - Epoch-based to prevent reuse -- Aggregation via leanVM (previously leanMultisig) for efficiency +- Signing, verification, and aggregation all come from leanVM, which internalized + XMSS in its own `xmss` crate; there is no external leanSig dependency +- `ethlambda_crypto::init_leanvm(use_arena)` must run once at startup, before any + proving or proof decoding. `--prover-arena` opts into leanVM's bump arena, + which trades bounded RSS for proving throughput **Signature Aggregation (Two-Phase):** 1. **Gossip signatures**: Fresh XMSS from network → aggregate via leanVM @@ -307,7 +313,7 @@ one port is supported and not a misconfiguration. See [`docs/rpc.md`](docs/rpc.m ```yaml GENESIS_TIME: 1770407233 GENESIS_VALIDATORS: - - attestation_pubkey: "cd323f232b34ab26d6db7402c886e74ca81cfd3a..." # 52-byte XMSS pubkeys (hex) + - attestation_pubkey: "cd323f232b34ab26d6db7402c886e74ca81cfd3a..." # XMSS pubkeys, hex, PUBLIC_KEY_SIZE bytes proposal_pubkey: "b7b0f72e24801b02bda64073cb4de6699a416b37..." ``` - Validator indices are assigned sequentially (0, 1, 2, ...) based on array order @@ -392,7 +398,8 @@ behavior. ## External Dependencies **Critical:** -- `leansig`: XMSS signatures (leanEthereum project) +- `xmss` / `lean-multisig` (leanVM): XMSS signatures and recursive aggregation, + pinned to one revision (leanEthereum project) - `libssz` / `libssz-derive` / `libssz-types`: SSZ serialization - `libssz-merkle`: Merkle tree hashing (`hash_tree_root()`) - `spawned-concurrency`: Actor model diff --git a/Cargo.lock b/Cargo.lock index 6cc31fe9..001ba9fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1998,7 +1998,6 @@ dependencies = [ "hex", "libssz", "libssz-types", - "rand 0.10.1", "rayon", "serde", "spawned-concurrency", @@ -2017,7 +2016,6 @@ dependencies = [ "hex", "lean-multisig", "postcard", - "rand 0.10.1", "thiserror 2.0.18", "tracing", "xmss", @@ -2126,7 +2124,6 @@ dependencies = [ "libssz-derive", "libssz-types", "lru", - "rand 0.10.1", "rocksdb", "tempfile", "thiserror 2.0.18", diff --git a/bin/ethlambda/build.rs b/bin/ethlambda/build.rs index aea5f542..c1497e22 100644 --- a/bin/ethlambda/build.rs +++ b/bin/ethlambda/build.rs @@ -2,10 +2,10 @@ use std::path::PathBuf; use vergen_git2::{Emitter, Git2Builder, RustcBuilder}; -/// Crate names whose resolved git revision is embedded in the binary, one per -/// upstream crypto repository: `leansig` for leanSig, `lean-multisig` for -/// leanVM (the direct dependency `ethlambda-crypto` builds against). -const LEANSIG_PACKAGE: &str = "leansig"; +/// Crate name whose resolved git revision is embedded in the binary. +/// leanVM owns the whole crypto stack (it internalized XMSS), and every crate +/// ethlambda takes from it resolves to one revision, so this single pin +/// identifies the crypto the binary was built against. const LEANVM_PACKAGE: &str = "lean-multisig"; fn main() -> Result<(), Box> { @@ -25,25 +25,16 @@ fn main() -> Result<(), Box> { Ok(()) } -/// Embed the resolved leanSig and leanVM git revisions from the workspace -/// Cargo.lock. +/// Embed the resolved leanVM git revision from the workspace Cargo.lock. /// -/// The crypto dependencies are pinned upstream (leansig to a moving branch, -/// leanVM to a rev), so a `cargo update` or a rev bump changes the measured -/// crypto with little or no ethlambda diff; benchmark reports embed these -/// revisions to keep results interpretable across lock bumps. +/// leanVM is pinned to a rev upstream, so a `cargo update` or a rev bump +/// changes the measured crypto with little or no ethlambda diff; benchmark +/// reports embed the revision to keep results interpretable across lock bumps. fn emit_crypto_revs() { - let revs = lockfile_git_revs(); - for (package, env_var) in [ - (LEANSIG_PACKAGE, "ETHLAMBDA_LEANSIG_REV"), - (LEANVM_PACKAGE, "ETHLAMBDA_LEANVM_REV"), - ] { - let rev = revs - .as_ref() - .and_then(|revs| revs.get(package).cloned()) - .unwrap_or_else(|| "unknown".to_string()); - println!("cargo:rustc-env={env_var}={rev}"); - } + let rev = lockfile_git_revs() + .and_then(|revs| revs.get(LEANVM_PACKAGE).cloned()) + .unwrap_or_else(|| "unknown".to_string()); + println!("cargo:rustc-env=ETHLAMBDA_LEANVM_REV={rev}"); if let Some(lockfile) = workspace_lockfile() { println!("cargo:rerun-if-changed={}", lockfile.display()); } @@ -77,7 +68,7 @@ fn lockfile_git_revs() -> Option> { source = Some(value.trim_matches('"').to_string()); } } - // source = "git+https://github.com/leanEthereum/leanSig?branch=devnet4#" + // source = "git+https://github.com/leanEthereum/leanVM.git?rev=#" let (Some(name), Some(source)) = (name, source) else { continue; }; diff --git a/bin/ethlambda/src/benchmark/corpus.rs b/bin/ethlambda/src/benchmark/corpus.rs index 8c28cbc6..fd597b59 100644 --- a/bin/ethlambda/src/benchmark/corpus.rs +++ b/bin/ethlambda/src/benchmark/corpus.rs @@ -8,7 +8,7 @@ use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ attestation::{AggregationBits, HashedAttestationData}, block::SingleMessageAggregate, - state::{State, Validator, ValidatorPubkeyBytes}, + state::{PUBLIC_KEY_SIZE, State, Validator, ValidatorPubkeyBytes}, }; /// Fixed genesis time for synthetic runs. The harness derives every tick @@ -119,7 +119,7 @@ fn splitmix64(state: &mut u64) -> u64 { } fn synthetic_pubkey(rng_state: &mut u64) -> ValidatorPubkeyBytes { - let mut bytes = [0u8; 52]; + let mut bytes = [0u8; PUBLIC_KEY_SIZE]; for chunk in bytes.chunks_mut(8) { let word = splitmix64(rng_state).to_le_bytes(); chunk.copy_from_slice(&word[..chunk.len()]); diff --git a/bin/ethlambda/src/benchmark/report.rs b/bin/ethlambda/src/benchmark/report.rs index 34e8014c..b1d14c4c 100644 --- a/bin/ethlambda/src/benchmark/report.rs +++ b/bin/ethlambda/src/benchmark/report.rs @@ -41,11 +41,9 @@ pub(crate) struct Sample { #[derive(Debug, Serialize)] pub(crate) struct Environment { pub client_version: &'static str, - /// Resolved leansig git revision from Cargo.lock. leansig is pinned to a - /// moving branch, so results are not comparable across revisions. - pub leansig_rev: &'static str, - /// Resolved leanVM git revision from Cargo.lock. leanVM does the signature - /// aggregation, so a rev bump moves the measured crypto too. + /// Resolved leanVM git revision from Cargo.lock. leanVM owns the whole + /// signature stack (XMSS and aggregation), so a rev bump moves the + /// measured crypto and results are not comparable across revisions. pub leanvm_rev: &'static str, pub os: &'static str, pub arch: &'static str, @@ -56,7 +54,6 @@ impl Environment { pub(crate) fn collect() -> Self { Self { client_version: version::CLIENT_VERSION, - leansig_rev: env!("ETHLAMBDA_LEANSIG_REV"), leanvm_rev: env!("ETHLAMBDA_LEANVM_REV"), os: std::env::consts::OS, arch: std::env::consts::ARCH, @@ -185,13 +182,8 @@ impl Report { ); let _ = writeln!( out, - " {} leansig={} leanvm={} os={} arch={} threads={}", - env.client_version, - env.leansig_rev, - env.leanvm_rev, - env.os, - env.arch, - env.available_parallelism + " {} leanvm={} os={} arch={} threads={}", + env.client_version, env.leanvm_rev, env.os, env.arch, env.available_parallelism ); let _ = writeln!(out); diff --git a/crates/blockchain/Cargo.toml b/crates/blockchain/Cargo.toml index 3aa4af73..342ae0b0 100644 --- a/crates/blockchain/Cargo.toml +++ b/crates/blockchain/Cargo.toml @@ -39,7 +39,6 @@ hex = { workspace = true } libssz.workspace = true libssz-types.workspace = true datatest-stable = "0.3.3" -rand.workspace = true [[test]] name = "forkchoice_spectests" diff --git a/crates/common/crypto/Cargo.toml b/crates/common/crypto/Cargo.toml index 4f60b53b..d8284403 100644 --- a/crates/common/crypto/Cargo.toml +++ b/crates/common/crypto/Cargo.toml @@ -18,7 +18,6 @@ ssz.workspace = true postcard.workspace = true thiserror.workspace = true -rand.workspace = true tracing.workspace = true [features] diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index c2d69174..5bc21e89 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -51,10 +51,11 @@ pub type ByteList512KiB = ByteList<524_288>; /// Also known as a *type-2* proof: leanVM's term for an aggregate binding /// several distinct messages, each with its own participant set. /// -/// The proof bytes are the `to_bytes()` (postcard) form of leanVM's -/// `MultiMessageAggregateSignature`, which embeds each component's participant -/// pubkeys. SSZ encoding this container adds the offset required for its -/// variable-length field. +/// The proof bytes are the `to_bytes_without_pubkeys()` form of leanVM's +/// `MultiMessageAggregateSignature`: participant pubkeys stay off the wire, and +/// a verifier rebuilds each component's set from the surrounding block body +/// before decoding. SSZ encoding this container adds the offset required for +/// its variable-length field. #[derive(Debug, Default, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] pub struct MultiMessageAggregate { /// Serialized multi-message aggregate proof bytes. @@ -121,15 +122,18 @@ pub const MAX_ATTESTATIONS_DATA: usize = 8; /// building a block proof. /// /// `participants` and `proof` are independent fields: the proof bytes are the -/// `to_bytes()` form of leanVM's `SingleMessageAggregateSignature` (which -/// embeds the participant pubkeys); `participants` is the bitfield identifying -/// which validators are bound. The verifier resolves the expected pubkeys from -/// `participants` and cross-checks them against the set embedded in the proof. +/// `to_bytes_without_pubkeys()` form of leanVM's +/// `SingleMessageAggregateSignature`, and `participants` is the bitfield +/// identifying which validators are bound. Pubkeys are not on the wire: the +/// verifier resolves them from `participants` against its own validator +/// registry and attaches them when decoding. Attaching a set other than the one +/// aggregated succeeds at decode and fails at verification, since the proof +/// binds a hash of the set. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct SingleMessageAggregate { /// Bitfield identifying validators bound by this proof. pub participants: AggregationBits, - /// Aggregated proof bytes in leanVM's `to_bytes()` form (pubkeys embedded). + /// Aggregated proof bytes in leanVM's `to_bytes_without_pubkeys()` form. pub proof: ByteList512KiB, } diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index dea59596..f230862d 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -25,4 +25,3 @@ lru.workspace = true [dev-dependencies] tempfile = "3" -rand.workspace = true diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 924437b7..bfc9277b 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -79,7 +79,7 @@ otherwise, because a mis-attributed report is worse than no report. Block-building benchmark — synthetic workload (mock crypto) validators=8 warmup_slots=8 iterations=10 proofs_per_data=1 seed=42 enable_proposer_aggregation=false max_attestations_per_block=3 - ethlambda/v0.1.0/aarch64-apple-darwin/rustc-v1.97.1 leansig=15cbdd43 leanvm=e2592df4 os=macos arch=aarch64 threads=14 + ethlambda/v0.1.0/aarch64-apple-darwin/rustc-v1.97.1 leanvm=a5909d18 os=macos arch=aarch64 threads=14 iter compact select_payloads stf_simulate overhead wall root 1 0.000ms 0.002ms 0.015ms 0.068ms 0.085ms 0x7282cc99 @@ -112,10 +112,9 @@ Same seed and same parameters produce identical root sequences, so a baseline and a candidate can be diffed directly. The header line exists to tell you when they *cannot* be compared: -- `leansig` and `leanvm` are the resolved revisions the binary was built - against, read from `Cargo.lock` at build time. leanSig tracks a moving branch - and leanVM performs the signature aggregation, so either one moving changes - the measured crypto. +- `leanvm` is the resolved revision the binary was built against, read from + `Cargo.lock` at build time. leanVM owns the whole signature stack (XMSS and + aggregation), so a rev bump changes the measured crypto. - `os`, `arch` and `threads` change results across machines. Two reports that disagree on any of those are not measuring the same thing. From 9c6e66f0df1aa394d295f27774902c22aa3c8f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:50:58 -0300 Subject: [PATCH 18/20] build(deps): track leanVM main's unified aggregation API leanVM `main` moved from a5909d18 (2026-07-30) to 5a4f55c1 (2026-09-03), 718 commits, and reshaped both halves of the crypto surface. The whole stack now arrives through leanVM's own facade crate, `leanvm`, which re-exports the aggregation API, the `xmss` module and the `rand` signing draws from: one dependency at one revision, in place of `xmss` + `lean-multisig` + a direct `ethereum_ssz`. ## One aggregate type, grouped by epoch `SingleMessageAggregateSignature` and `MultiMessageAggregateSignature` are gone. There is one `AggregateSignature`, whose XMSS claims are grouped by epoch: one `XmssGroup` per slot, carrying the one message signed at it and that group's sorted, deduplicated keys. Type-1 and Type-2 are now the same object, one group versus several, so the wrappers here differ only in how many groups they build. | before | after | | --- | --- | | `aggregate_single_message_signatures(children, raw, msg, slot, rate)` | `aggregate(children, raw_xmss, raw_sphincs, declare, rate)` | | `merge_single_message_aggregates(type_1s, rate)` | the same `aggregate`, children only | | `split_multi_message_aggregate(t2, index, rate)` | the same `aggregate`, `declare` naming the group to keep | | `verify_single_message_aggregate` / `verify_multi_message_aggregate` | `sig.verify()` | ## The binding left the wire `to_bytes_without_pubkeys()` used to carry `(message, slot)` inside the proof, so `verify_*` could compare it against what the caller expected and `split_*` could search the decoded components for a message. Neither is possible now: the epoch and the message live in the `XmssGroup`, which is exactly the part the without-pubkeys form omits. Every decode has to rebuild the whole signer set from the caller's own view of it, which is what the new `SignerSet` carries. Message, slot and keys travel together because none of the three can be supplied without the others. Three wrapper signatures change as a result: - `merge_type_1s_into_type_2` takes `(SignerSet, bytes)` per child. - `verify_type_2_signature` takes `&[SignerSet]` in place of parallel pubkey and binding lists. - `split_type_2_by_message` takes `&[SignerSet]` for the whole parent, since decoding it needs every claim even though one survives. `VerificationError::BindingMismatch` goes with them. A wrong message or slot changes the digest the proof is checked against, so it now fails inside the SNARK verifier rather than at a field compare ahead of it: the check is not weaker, it just costs a full verification to reach. ## A slot carries one message `ConflictingMessages` is new upstream: within one aggregate the message is a function of the epoch. Two distinct `AttestationData` at one slot have no representation in a single proof, and `on_block` rejects only duplicate `AttestationData`, not two entries at one slot, so a fork that splits attesters at slot N yields a block this leanVM cannot prove. Surfaced as `ethlambda_crypto::ConflictingMessages` rather than worked around: leanSpec still models a block's components as parallel `(root, slot)` lists, so the protocol question is open upstream. ## XMSS leanVM's `xmss` is BLAKE2s over binary fields now, not Poseidon over KoalaBear. The wire sizes are unchanged by coincidence (32-byte keys, 1208-byte signatures, both still static-asserted), but every genesis key and every stored proof from before the rewrite is invalid. The call surface moved too: `key_gen_from_seed` returns `(secret, public)` over an inclusive epoch range, `sign` draws its randomness from a caller-supplied rng, and `verify` reorders its arguments. The preparation window is gone, a key's signable range being fixed at generation, and `XmssSecretKey::prepare` only warms the cached bottom subtree. `KeyManager::advance_keys_to`, which looped an `advance_preparation()` that had already become a no-op, is now `prepare_keys_for`; an unsignable slot is named as exhaustion where it is reached. ## Testing Every `ethlambda-crypto` test passes against the new backend under `--include-ignored`: the Type-2 round trip (aggregate two Type-1s, merge, verify, narrow back to one claim, verify that), the three negative binding tests, the arena path, and the XMSS sign/verify round trip. Two new unit tests pin the signer-set construction every decode depends on. Fixture-driven spec tests still fail exactly as they did before this commit (122 forkchoice, 73 stf, 7 ssz, 3 signature), all at deserialization: leanSpec's latest released fixtures are still on the 52-byte scheme. --- CLAUDE.md | 24 +- Cargo.lock | 361 +++-------- Cargo.toml | 15 +- bin/ethlambda/build.rs | 2 +- bin/ethlambda/src/cli.rs | 12 +- crates/blockchain/src/block_builder.rs | 2 +- crates/blockchain/src/key_manager.rs | 80 +-- crates/blockchain/src/lib.rs | 61 +- crates/blockchain/src/reaggregate.rs | 43 +- crates/blockchain/src/store.rs | 54 +- .../blockchain/tests/signature_spectests.rs | 2 +- crates/common/crypto/Cargo.toml | 5 +- crates/common/crypto/src/lib.rs | 601 ++++++++++-------- crates/common/crypto/src/signature.rs | 127 ++-- crates/common/crypto/tests/arena.rs | 16 +- .../common/test-fixtures/src/fork_choice.rs | 4 +- .../test-fixtures/src/verify_signatures.rs | 4 +- docs/benchmarking.md | 4 +- 18 files changed, 688 insertions(+), 729 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 33fd67f6..4a7f5587 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,9 +268,26 @@ actual_slot = finalized_slot + 1 + relative_index - Epoch-based to prevent reuse - Signing, verification, and aggregation all come from leanVM, which internalized XMSS in its own `xmss` crate; there is no external leanSig dependency +- BLAKE2s over binary fields, not Poseidon over KoalaBear: a leanVM `main` bump + across that rewrite invalidates every genesis key and every stored proof, even + when the wire sizes happen to match - `ethlambda_crypto::init_leanvm(use_arena)` must run once at startup, before any proving or proof decoding. `--prover-arena` opts into leanVM's bump arena, - which trades bounded RSS for proving throughput + which recycles the prover's large buffers across proofs instead of re-faulting + them, so its pages stay resident for the node's lifetime + +**Aggregation shape (one leanVM `AggregateSignature`, grouped by epoch):** +- Type-1 and Type-2 are the same object: one `XmssGroup` per slot, carrying the + one message signed at it and that group's sorted, deduplicated keys +- **A slot carries one message.** Two distinct `AttestationData` at one slot + cannot share an aggregate; `ethlambda_crypto::ConflictingMessages` says so, and + nothing below it can work around the constraint +- **The binding is off the wire.** `to_bytes_without_pubkeys()` carries neither + the keys nor the `(slot, message)` pairs, so every decode rebuilds the whole + signer set from a `SignerSet` per claim. A wrong set, message or slot decodes + fine and fails inside the SNARK verifier, so there is no cheap binding check +- Narrowing replaces splitting: re-aggregate the parent with a `declare` naming + the group to keep (`split_type_2_by_message`) **Signature Aggregation (Two-Phase):** 1. **Gossip signatures**: Fresh XMSS from network → aggregate via leanVM @@ -405,8 +422,9 @@ behavior. ## External Dependencies **Critical:** -- `xmss` / `lean-multisig` (leanVM): XMSS signatures and recursive aggregation, - pinned to one revision (leanEthereum project) +- `leanvm`: XMSS signatures and recursive aggregation, taken from leanVM's facade + crate (which re-exports `xmss`, `rec_aggregation` and its `rand`) and pinned to + one `main` revision (leanEthereum project) - `libssz` / `libssz-derive` / `libssz-types`: SSZ serialization - `libssz-merkle`: Merkle tree hashing (`hash_tree_root()`) - `spawned-concurrency`: Actor model diff --git a/Cargo.lock b/Cargo.lock index 001ba9fa..4c77fc05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,16 +74,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "air" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "field", - "koala-bear", - "poly", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -657,24 +647,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "backend" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "air", - "fiat-shamir", - "field", - "koala-bear", - "parallel", - "poly", - "sumcheck", - "symetric", - "utils", - "whir", - "zk-alloc", -] - [[package]] name = "backtrace" version = "0.3.76" @@ -730,6 +702,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -2011,14 +1992,12 @@ dependencies = [ name = "ethlambda-crypto" version = "0.1.0" dependencies = [ - "ethereum_ssz", "ethlambda-types", "hex", - "lean-multisig", + "leanvm", "postcard", "thiserror 2.0.18", "tracing", - "xmss", ] [[package]] @@ -2524,27 +2503,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] -name = "fiat-shamir" +name = "fiat_shamir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" dependencies = [ - "field", - "koala-bear", "parallel", + "primitives", "serde", - "symetric", - "utils", -] - -[[package]] -name = "field" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "paste", - "rand 0.10.1", - "serde", - "utils", ] [[package]] @@ -2575,6 +2540,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flock" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" +dependencies = [ + "fiat_shamir", + "parallel", + "pcs", + "primitives", + "zk_alloc", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3422,25 +3399,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "include_dir" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" -dependencies = [ - "proc-macro2", - "quote", -] - [[package]] name = "indenter" version = "0.3.4" @@ -3703,18 +3661,6 @@ dependencies = [ "sha3-asm", ] -[[package]] -name = "koala-bear" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "field", - "paste", - "rand 0.10.1", - "serde", - "utils", -] - [[package]] name = "konst" version = "0.2.20" @@ -3764,59 +3710,42 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lean-multisig" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "backend", - "clap", - "lean_vm", - "rec_aggregation", - "serde_json", - "sub_protocols", - "system-info", - "xmss", - "zk-alloc", -] - [[package]] name = "lean_compiler" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" dependencies = [ - "backend", - "include_dir", "lean_vm", - "pest", - "pest_derive", - "sub_protocols", - "xmss", + "primitives", ] [[package]] -name = "lean_prover" +name = "lean_vm" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" dependencies = [ - "backend", - "lean_compiler", - "lean_vm", - "rand 0.10.1", - "serde", - "sub_protocols", + "fiat_shamir", + "flock", + "parallel", + "pcs", + "primitives", "tracing", - "xmss", + "zk_alloc", ] [[package]] -name = "lean_vm" +name = "leanvm" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" dependencies = [ - "backend", - "tracing", + "clap", + "lean_vm", + "primitives", + "rand 0.9.4", + "rec_aggregation", + "sphincs", "xmss", + "zk_alloc", ] [[package]] @@ -5121,16 +5050,6 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.11.1", - "objc2", -] - [[package]] name = "object" version = "0.37.3" @@ -5192,9 +5111,9 @@ dependencies = [ [[package]] name = "parallel" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" dependencies = [ - "system-info", + "libc", ] [[package]] @@ -5260,6 +5179,19 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pcs" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" +dependencies = [ + "fiat_shamir", + "parallel", + "primitives", + "serde", + "tracing", + "zk_alloc", +] + [[package]] name = "pem" version = "3.0.6" @@ -5295,39 +5227,6 @@ dependencies = [ "ucd-trie", ] -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2", -] - [[package]] name = "pin-project" version = "1.1.13" @@ -5384,21 +5283,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "poly" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "field", - "koala-bear", - "parallel", - "rand 0.10.1", - "serde", - "system-info", - "utils", - "zk-alloc", -] - [[package]] name = "poly1305" version = "0.8.0" @@ -5532,6 +5416,19 @@ dependencies = [ "uint 0.10.0", ] +[[package]] +name = "primitives" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" +dependencies = [ + "libc", + "parallel", + "serde", + "tracing-forest", + "tracing-subscriber", + "zk_alloc", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5971,19 +5868,18 @@ dependencies = [ [[package]] name = "rec_aggregation" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" dependencies = [ - "backend", - "include_dir", + "bincode", + "flock", "lean_compiler", - "lean_prover", "lean_vm", - "objc2", - "objc2-foundation", "parallel", - "postcard", + "pcs", + "primitives", + "rand 0.9.4", "serde", - "sub_protocols", + "sphincs", "tracing", "xmss", ] @@ -6850,6 +6746,17 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "sphincs" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" +dependencies = [ + "parallel", + "primitives", + "rand 0.9.4", + "serde", +] + [[package]] name = "spin" version = "0.9.8" @@ -6914,48 +6821,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "sub_protocols" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "backend", - "lean_vm", - "tracing", -] - [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "sumcheck" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "air", - "fiat-shamir", - "field", - "koala-bear", - "parallel", - "poly", - "tracing", - "zk-alloc", -] - -[[package]] -name = "symetric" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "field", - "koala-bear", - "parallel", - "zk-alloc", -] - [[package]] name = "syn" version = "1.0.109" @@ -7044,14 +6915,6 @@ dependencies = [ "libc", ] -[[package]] -name = "system-info" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "libc", -] - [[package]] name = "tagptr" version = "0.2.0" @@ -7576,17 +7439,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "utils" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "parallel", - "serde", - "tracing-forest", - "tracing-subscriber", -] - [[package]] name = "uuid" version = "1.23.1" @@ -7846,25 +7698,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "whir" -version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" -dependencies = [ - "fiat-shamir", - "field", - "koala-bear", - "parallel", - "poly", - "rand 0.10.1", - "sumcheck", - "symetric", - "system-info", - "tracing", - "utils", - "zk-alloc", -] - [[package]] name = "wide" version = "0.7.33" @@ -8407,12 +8240,12 @@ dependencies = [ [[package]] name = "xmss" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" dependencies = [ - "backend", "ethereum_ssz", - "postcard", - "rand 0.10.1", + "parallel", + "primitives", + "rand 0.9.4", "serde", ] @@ -8574,13 +8407,11 @@ dependencies = [ ] [[package]] -name = "zk-alloc" +name = "zk_alloc" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanVM.git?rev=a5909d18647de6aed38640c098d9177fab2bf36a#a5909d18647de6aed38640c098d9177fab2bf36a" +source = "git+https://github.com/leanEthereum/leanVM.git?rev=5a4f55c1138759f43f78483a7b70fde973e4a1ee#5a4f55c1138759f43f78483a7b70fde973e4a1ee" dependencies = [ "libc", - "parallel", - "system-info", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8abb81ce..eef00613 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,15 +81,12 @@ prometheus = "0.14" clap = { version = "4.3", features = ["derive", "env"] } -# XMSS signatures + recursive aggregation (leanVM). -# leanVM's `main` internalized XMSS (dropping the external leanSig dependency) -# and reworked the aggregation API (see "Xmss api rework"). -# Pinned to a `main` commit for reproducible builds; bump the revs to track main. -xmss = { git = "https://github.com/leanEthereum/leanVM.git", rev = "a5909d18647de6aed38640c098d9177fab2bf36a" } -lean-multisig = { git = "https://github.com/leanEthereum/leanVM.git", rev = "a5909d18647de6aed38640c098d9177fab2bf36a" } - -# SSZ codec used by leanVM's xmss pubkey/signature types (ethereum_ssz, not libssz). -ssz = { package = "ethereum_ssz", version = "0.10" } +# XMSS signatures + recursive aggregation, through leanVM's own facade crate: +# it re-exports the aggregation API, the `xmss` module (keys, signing, and the +# SSZ codec for the two wire types) and the `rand` that signing draws from, so +# the whole crypto stack arrives as one dependency at one revision. +# Pinned to a `main` commit for reproducible builds; bump the rev to track main. +leanvm = { git = "https://github.com/leanEthereum/leanVM.git", rev = "5a4f55c1138759f43f78483a7b70fde973e4a1ee" } # Secret-key (de)serialization for the leanVM xmss key format. postcard = { version = "1.1.3", features = ["alloc"] } diff --git a/bin/ethlambda/build.rs b/bin/ethlambda/build.rs index c1497e22..78f8534d 100644 --- a/bin/ethlambda/build.rs +++ b/bin/ethlambda/build.rs @@ -6,7 +6,7 @@ use vergen_git2::{Emitter, Git2Builder, RustcBuilder}; /// leanVM owns the whole crypto stack (it internalized XMSS), and every crate /// ethlambda takes from it resolves to one revision, so this single pin /// identifies the crypto the binary was built against. -const LEANVM_PACKAGE: &str = "lean-multisig"; +const LEANVM_PACKAGE: &str = "leanvm"; fn main() -> Result<(), Box> { let git2 = Git2Builder::default().branch(true).sha(true).build()?; diff --git a/bin/ethlambda/src/cli.rs b/bin/ethlambda/src/cli.rs index 0db6095d..d6461bc3 100644 --- a/bin/ethlambda/src/cli.rs +++ b/bin/ethlambda/src/cli.rs @@ -107,11 +107,13 @@ pub(crate) struct NodeOptions { pub(crate) enable_proposer_aggregation: bool, /// Prove on leanVM's bump arena instead of the system allocator. /// - /// Buys proving throughput with memory: the arena never returns pages to - /// the OS, so RSS ratchets up to the process's allocation high-water mark - /// and stays there for the lifetime of the node. Off by default so a - /// long-lived node keeps bounded memory; worth enabling on hosts with - /// memory to spare where proving latency is the constraint. + /// Buys proving throughput with memory: the arena's slabs stay faulted in + /// across proofs (a phase reset abandons their contents, it does not return + /// the pages), and on Linux it also stops glibc trimming its own heap. RSS + /// therefore ratchets up to the process's allocation high-water mark and + /// stays there for the lifetime of the node. Off by default so a long-lived + /// node keeps bounded memory; worth enabling on hosts with memory to spare + /// where proving latency is the constraint. /// /// Read once at startup: the allocator is fixed before the first proof. #[arg(long, default_value = "false")] diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 6093e6d6..59fbb1ec 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -1101,7 +1101,7 @@ mod tests { ); // Substitute a worst-case-size proof to model what `propose_block` - // would attach. The actual SNARK can't be built without lean-multisig, + // would attach. The actual SNARK can't be built without leanVM, // but the size cap (`ByteList512KiB`) bounds the worst case. let _ = signatures; let proof = MultiMessageAggregate::new( diff --git a/crates/blockchain/src/key_manager.rs b/crates/blockchain/src/key_manager.rs index eb2777a8..26196ec4 100644 --- a/crates/blockchain/src/key_manager.rs +++ b/crates/blockchain/src/key_manager.rs @@ -6,7 +6,7 @@ use ethlambda_types::{ attestation::{AttestationData, XmssSignature}, primitives::{H256, HashTreeRoot as _}, }; -use tracing::{info, warn}; +use tracing::{trace, warn}; use crate::metrics; @@ -23,9 +23,8 @@ pub enum KeyManagerError { /// A validator's dual XMSS key pair for attestation and block proposal signing. /// -/// Each key is independent and advances its OTS preparation separately, -/// allowing the validator to sign both an attestation and a block proposal -/// within the same slot. +/// Each key holds its own one-time leaves, so the validator can sign both an +/// attestation and a block proposal within the same slot. pub struct ValidatorKeyPair { pub attestation_key: ValidatorSecretKey, pub proposal_key: ValidatorSecretKey, @@ -49,14 +48,19 @@ impl KeyManager { self.keys.keys().copied().collect() } - /// Advances every validator's XMSS preparation windows to cover slot - pub fn advance_keys_to(&mut self, slot: u32) { - for (validator_id, key_pair) in self.keys.iter_mut() { - let _ = advance_key(*validator_id, &mut key_pair.attestation_key, slot).inspect_err( - |err| warn!(validator_id, slot, %err, "Failed to advance attestation key preparation window"), + /// Warms every validator's signing cache for `slot`. + /// + /// Pure latency shifting: the key rebuilds the same bottom Merkle subtree + /// inside `sign` on a miss, so this only moves that cost off the duty's + /// critical path. Called one slot ahead, so a miss here is not yet a + /// failure to sign. + pub fn prepare_keys_for(&self, slot: u32) { + for (validator_id, key_pair) in &self.keys { + let _ = prepare_key(&key_pair.attestation_key, slot).inspect_err( + |err| warn!(validator_id, slot, %err, "Failed to warm attestation key signing cache"), ); - let _ = advance_key(*validator_id, &mut key_pair.proposal_key, slot).inspect_err( - |err| warn!(validator_id, slot, %err, "Failed to advance proposal key preparation window"), + let _ = prepare_key(&key_pair.proposal_key, slot).inspect_err( + |err| warn!(validator_id, slot, %err, "Failed to warm proposal key signing cache"), ); } } @@ -93,10 +97,10 @@ impl KeyManager { .get_mut(&validator_id) .ok_or(KeyManagerError::ValidatorKeyNotFound(validator_id))?; - // Advance XMSS key preparation window if the slot is outside the current window. - // Each bottom tree covers 65,536 slots; the window holds 2 at a time. - // Multiple advances may be needed if the node was offline for an extended period. - advance_key(validator_id, &mut key_pair.attestation_key, slot)?; + // A slot outside the key's range can never be signed, however long the + // node waits, so name that rather than letting it surface as a generic + // signing error. + signable_at(validator_id, &key_pair.attestation_key, slot)?; let signature: ValidatorSignature = { let _timing = metrics::time_pq_sig_attestation_signing(); @@ -123,10 +127,7 @@ impl KeyManager { .get_mut(&validator_id) .ok_or(KeyManagerError::ValidatorKeyNotFound(validator_id))?; - // Advance XMSS key preparation window if the slot is outside the current window. - // Each bottom tree covers 65,536 slots; the window holds 2 at a time. - // Multiple advances may be needed if the node was offline for an extended period. - advance_key(validator_id, &mut key_pair.proposal_key, slot)?; + signable_at(validator_id, &key_pair.proposal_key, slot)?; let signature: ValidatorSignature = key_pair .proposal_key @@ -139,32 +140,33 @@ impl KeyManager { } } -fn advance_key( +/// Reject a slot the key cannot sign at. +/// +/// The signable range is fixed at key generation, so this is exhaustion, not a +/// window that will catch up. +fn signable_at( validator_id: u64, - key: &mut ValidatorSecretKey, + key: &ValidatorSecretKey, slot: u32, ) -> Result<(), KeyManagerError> { - if key.is_prepared_for(slot) { + if key.can_sign_at(slot) { return Ok(()); } - info!(validator_id, slot, "Advancing XMSS key preparation window"); + let range = key.signable_slots(); + Err(KeyManagerError::SigningError(format!( + "XMSS key exhausted for validator {validator_id}: slot {slot} is outside \ + the key's signable range [{}, {}]", + range.start(), + range.end() + ))) +} + +/// Warm one key's signing cache, timing the miss that rebuilds a subtree. +fn prepare_key(key: &ValidatorSecretKey, slot: u32) -> Result<(), KeyManagerError> { let start = Instant::now(); - while !key.is_prepared_for(slot) { - let before = key.get_prepared_interval(); - key.advance_preparation(); - if key.get_prepared_interval() == before { - return Err(KeyManagerError::SigningError(format!( - "XMSS key exhausted for validator {validator_id}: \ - slot {slot} is beyond the key's activation interval" - ))); - } - } - info!( - validator_id, - slot, - elapsed = ?start.elapsed(), - "Advanced XMSS key preparation window" - ); + key.prepare(slot) + .map_err(|err| KeyManagerError::SigningError(err.to_string()))?; + trace!(slot, elapsed = ?start.elapsed(), "Warmed XMSS signing cache"); Ok(()) } diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 8d678e22..41986692 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::time::{Duration, Instant, SystemTime}; +use ethlambda_crypto::SignerSet; use ethlambda_crypto::signature::{ValidatorPublicKey, ValidatorSignature}; use ethlambda_network_api::{BlockChainToP2PRef, BlockSource, InitP2P}; use ethlambda_state_transition::is_proposer; @@ -174,15 +175,15 @@ impl BlockChain { metrics::set_node_sync_status(metrics::SyncStatus::Idle); let time_config = *store.config(); let genesis_time = time_config.genesis_time; - let mut key_manager = key_manager::KeyManager::new(validator_keys); + let key_manager = key_manager::KeyManager::new(validator_keys); - // Catch XMSS keys up to the current slot before the first tick + // Warm the XMSS signing caches for the current slot before the first tick. // store.time() doesn't work here: after an offline gap it lags wall-clock by - // exactly the gap we need to catch up through + // exactly the gap the first duty will be at let now_ms = unix_now_ms(); let current_slot = (now_ms.saturating_sub(time_config.genesis_time_ms()) / time_config.milliseconds_per_slot) as u32; - key_manager.advance_keys_to(current_slot); + key_manager.prepare_keys_for(current_slot); let handle = BlockChainServer { store, @@ -475,8 +476,8 @@ impl BlockChainServer { // Update head slot metric (head may change when attestations are promoted at intervals 0/4) metrics::update_head_slot(self.store.head_slot()); - // Advance XMSS keys for next slot so the signing paths don't have to - self.key_manager.advance_keys_to((slot + 1) as u32); + // Warm the XMSS signing caches for the next slot so the signing paths don't have to + self.key_manager.prepare_keys_for((slot + 1) as u32); } /// Kick off a committee-signature aggregation session: @@ -836,10 +837,33 @@ impl BlockChainServer { return; }; - let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = + // Each merge input pairs the proof bytes with the claim its Type-1 + // binds: the message, the slot, and the participants' keys. + // `single_message_aggregates` is ordered to match + // `block.body.attestations`, which is how the claim is recovered for + // each proof. Checked rather than assumed: zipping two + // lists of different lengths would build a proof covering fewer claims + // than the body declares, which only surfaces at import. + if single_message_aggregates.len() != block.body.attestations.len() { + error!( + %slot, %validator_id, + aggregates = single_message_aggregates.len(), + attestations = block.body.attestations.len(), + "Proof list does not line up with the block body" + ); + metrics::inc_block_building_failures(); + return; + } + + let mut merge_inputs: Vec<(SignerSet, ByteList512KiB)> = Vec::with_capacity(single_message_aggregates.len() + 1); let mut resolve_failed = false; - for sma in &single_message_aggregates { + for (attestation, sma) in block + .body + .attestations + .iter() + .zip(&single_message_aggregates) + { let mut pubkeys = Vec::new(); for vid in sma.participant_indices() { let Some(validator) = validators.get(vid as usize) else { @@ -859,18 +883,27 @@ impl BlockChainServer { if resolve_failed { break; } - merge_inputs.push((pubkeys, sma.proof.clone())); + let Ok(attestation_slot) = u32::try_from(attestation.data.slot) else { + error!(%slot, %validator_id, attestation_slot = attestation.data.slot, + "Attestation slot out of range while assembling block"); + resolve_failed = true; + break; + }; + let claim = + SignerSet::new(attestation.data.hash_tree_root(), attestation_slot, pubkeys); + merge_inputs.push((claim, sma.proof.clone())); } if resolve_failed { metrics::inc_block_building_failures(); return; } - merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); + let proposer_claim = SignerSet::new(block_root, slot as u32, vec![proposer_pubkey]); + merge_inputs.push((proposer_claim, proposer_proof_bytes)); - // Merge yields raw lean-multisig type-2 bytes. Per-component - // participants are rederived at verify time from - // `block.body.attestations[i].aggregation_bits` plus - // `block.proposer_index`, so nothing else needs persisting. + // Merge yields raw leanVM aggregate bytes. Per-component participants + // and bindings are rederived at verify time from + // `block.body.attestations[i]` plus `block.proposer_index`, so nothing + // else needs persisting. let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { Ok(bytes) => bytes, Err(err) => { diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs index 13b433c5..c97d2cb7 100644 --- a/crates/blockchain/src/reaggregate.rs +++ b/crates/blockchain/src/reaggregate.rs @@ -24,6 +24,7 @@ use std::collections::HashSet; +use ethlambda_crypto::SignerSet; use ethlambda_crypto::signature::ValidatorPublicKey; use ethlambda_storage::Store; use ethlambda_types::{ @@ -71,11 +72,11 @@ pub fn reaggregate_from_block( let validators = &parent_state.validators; let num_validators = validators.len() as u64; - // Per-component pubkeys: one entry per body attestation in order, then - // the proposer entry. Layout is invariant per block, so it's resolved - // once and reused for every split call below. - let mut pubkeys_per_component: Vec> = - Vec::with_capacity(attestations.len() + 1); + // The claims the merged proof carries: one per body attestation in order, + // then the proposer's. The layout is invariant per block, so it is resolved + // once and reused for every split call below. Every split needs all of + // them, since none is on the wire and the decode rebuilds the whole set. + let mut components: Vec = Vec::with_capacity(attestations.len() + 1); for att in &attestations { let mut pubkeys = Vec::new(); for vid in validator_indices(&att.aggregation_bits) { @@ -91,7 +92,14 @@ pub fn reaggregate_from_block( }; pubkeys.push(pk); } - pubkeys_per_component.push(pubkeys); + let Ok(att_slot) = u32::try_from(att.data.slot) else { + warn!( + slot = att.data.slot, + "Reaggregation aborted: attestation slot out of range" + ); + return Vec::new(); + }; + components.push(SignerSet::new(att.data.hash_tree_root(), att_slot, pubkeys)); } if block.proposer_index >= num_validators { return Vec::new(); @@ -101,7 +109,18 @@ pub fn reaggregate_from_block( else { return Vec::new(); }; - pubkeys_per_component.push(vec![proposer_pubkey]); + let Ok(block_slot) = u32::try_from(block.slot) else { + warn!( + slot = block.slot, + "Reaggregation aborted: block slot out of range" + ); + return Vec::new(); + }; + components.push(SignerSet::new( + block.hash_tree_root(), + block_slot, + vec![proposer_pubkey], + )); let candidates = select_candidates(store, &attestations); if candidates.is_empty() { @@ -117,17 +136,15 @@ pub fn reaggregate_from_block( for candidate in candidates { let att = &attestations[candidate.idx]; let data_root = candidate.data_root; - let slot_u32: u32 = match att.data.slot.try_into() { - Ok(s) => s, - Err(_) => continue, - }; + // Already range-checked while the claims were resolved above. + let slot_u32 = components[candidate.idx].slot; // Step 1: SNARK-split this attestation's component out of the block's // merged multi-message aggregate proof. let merged_bytes = signed_block.proof.proof_bytes(); let split_bytes = match ethlambda_crypto::split_type_2_by_message( merged_bytes, - pubkeys_per_component.clone(), + &components, &data_root, ) { Ok(bytes) => bytes, @@ -152,7 +169,7 @@ pub fn reaggregate_from_block( // First child: the split-from-block proof, paired with the // pubkeys derived from the block attestation's participant set. - let block_att_pubkeys = pubkeys_per_component[candidate.idx].clone(); + let block_att_pubkeys = components[candidate.idx].public_keys.clone(); children.push((block_att_pubkeys, split_bytes)); // Remaining children: local partial single-message aggregates for the same data. diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 32460b00..f4eaae03 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; +use ethlambda_crypto::SignerSet; use ethlambda_crypto::signature::{ValidatorPublicKey, ValidatorSignature}; use ethlambda_state_transition::{is_proposer, slot_is_justifiable_after}; use ethlambda_storage::{ForkCheckpoints, Store}; @@ -1114,11 +1115,13 @@ pub enum StoreError { /// Full verification of a signed block's merged multi-message aggregate proof. /// -/// Structural pre-checks (fast fail) ensure the merged proof's `info` list lines -/// up with the block body (one entry per attestation plus a trailing proposer -/// entry; messages, slots, and participants match what the body declares). -/// On success, the lean-multisig devnet5 `verify_type_2` primitive runs the -/// SNARK verifier over the merged proof bytes against the resolved pubkey set. +/// Structural pre-checks (fast fail) bound the body itself: attestation count, +/// no duplicate `AttestationData`, and every participant and the proposer in +/// range of the validator registry. The claims the proof must carry are then +/// rederived from the body, one `(message, slot, pubkeys)` per attestation plus +/// a trailing proposer entry, and handed to leanVM's verifier. None of them is +/// on the wire, so a proof over other messages, slots or keys fails inside the +/// SNARK rather than at a compare ahead of it. /// /// Exposed publicly so RPC handlers (notably the Hive test-driver /// `verify_signatures/run` endpoint) can run the exact same verification path @@ -1160,14 +1163,13 @@ pub fn verify_block_signatures( let block_root = block.hash_tree_root(); let structural_elapsed = total_start.elapsed(); - // Resolve pubkeys per multi-message aggregate component for verify_type_2 and rederive the - // expected (message, slot) bindings from the block body. Attestation - // components use each participant's attestation_pubkey; the trailing - // proposer component uses the proposal_pubkey of `block.proposer_index`. + // Rederive the claims the merged proof must carry from the block body: one + // `(message, slot, pubkeys)` per attestation, then the proposer's. Nothing + // of this is on the wire, so it is the caller's view that binds the proof; + // attestation claims use each participant's attestation_pubkey, the + // trailing proposer claim the proposal_pubkey of `block.proposer_index`. let expected_components = attestations.len() + 1; - let mut pubkeys_per_component: Vec> = - Vec::with_capacity(expected_components); - let mut expected_bindings: Vec<(H256, u32)> = Vec::with_capacity(expected_components); + let mut components: Vec = Vec::with_capacity(expected_components); for attestation in attestations.iter() { let mut pubkeys = Vec::new(); @@ -1181,10 +1183,13 @@ pub fn verify_block_signatures( .map_err(|_| StoreError::PubkeyDecodingFailed(vid))?; pubkeys.push(pk); } - pubkeys_per_component.push(pubkeys); let slot_u32 = u32::try_from(attestation.data.slot) .map_err(|_| StoreError::SlotOutOfRange(attestation.data.slot))?; - expected_bindings.push((attestation.data.hash_tree_root(), slot_u32)); + components.push(SignerSet::new( + attestation.data.hash_tree_root(), + slot_u32, + pubkeys, + )); } let proposer_out_of_range = StoreError::ProposerIndexOutOfRange { @@ -1196,20 +1201,19 @@ pub fn verify_block_signatures( .ok_or(proposer_out_of_range)?; let proposer_pubkey = ValidatorPublicKey::from_bytes(&proposer_validator.proposal_pubkey) .map_err(|_| StoreError::PubkeyDecodingFailed(block.proposer_index))?; - pubkeys_per_component.push(vec![proposer_pubkey]); let block_slot_u32 = u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?; - expected_bindings.push((block_root, block_slot_u32)); + components.push(SignerSet::new( + block_root, + block_slot_u32, + vec![proposer_pubkey], + )); let merged_bytes = signed_block.proof.proof_bytes(); let crypto_start = std::time::Instant::now(); - ethlambda_crypto::verify_type_2_signature( - merged_bytes, - pubkeys_per_component, - &expected_bindings, - ) - .map_err(StoreError::BlockProofVerificationFailed)?; + ethlambda_crypto::verify_type_2_signature(merged_bytes, &components) + .map_err(StoreError::BlockProofVerificationFailed)?; let crypto_elapsed = crypto_start.elapsed(); let total_elapsed = total_start.elapsed(); @@ -1291,9 +1295,9 @@ mod tests { /// Test helper: placeholder block proof bytes. /// - /// In production the merged proof is the raw `compress_without_pubkeys()` - /// output of `merge_many_type_1`, which can only be built by the - /// lean-multisig prover. Tests that don't go through + /// In production the merged proof is the raw `to_bytes_without_pubkeys()` + /// output of a recursive aggregation over every Type-1, which can only be + /// built by the leanVM prover. Tests that don't go through /// `verify_block_signatures` use an empty blob. fn make_signed_block_proof( _proposer_index: u64, diff --git a/crates/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs index f1e1f8b9..64b6f7be 100644 --- a/crates/blockchain/tests/signature_spectests.rs +++ b/crates/blockchain/tests/signature_spectests.rs @@ -18,7 +18,7 @@ const SUPPORTED_FIXTURE_FORMAT: &str = "verify_signatures_test"; /// Tests that require cryptographic signature verification at block level. /// -/// Block-level crypto verification is now wired through lean-multisig devnet5's +/// Block-level crypto verification is now wired through leanVM's /// `verify_type_2`, so every fixture is exercised against the real primitive. const SKIP_TESTS: &[&str] = &[]; diff --git a/crates/common/crypto/Cargo.toml b/crates/common/crypto/Cargo.toml index d8284403..5fcf5a40 100644 --- a/crates/common/crypto/Cargo.toml +++ b/crates/common/crypto/Cargo.toml @@ -12,9 +12,7 @@ version.workspace = true [dependencies] ethlambda-types.workspace = true -xmss.workspace = true -lean-multisig.workspace = true -ssz.workspace = true +leanvm.workspace = true postcard.workspace = true thiserror.workspace = true @@ -25,4 +23,3 @@ shadow-integration = [] [dev-dependencies] hex.workspace = true -ssz.workspace = true diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index fbcc0cc3..384fe718 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -8,18 +8,32 @@ //! Everything else assumes it has run. Aggregation produces Type-1 proofs (one message, //! one slot) and Type-2 proofs (several messages merged); both travel without their //! participant pubkeys, which a receiver rebuilds from its own validator registry. +//! +//! # One aggregate type, grouped by slot +//! +//! leanVM has a single `AggregateSignature`, whose XMSS claims are grouped by +//! epoch: one [`XmssGroup`] per slot, carrying the one message signed at it and +//! the group's strictly sorted, deduplicated keys. Type-1 and Type-2 are the +//! same object here, one group versus several, and the wrappers below only +//! differ in how many groups they build. +//! +//! Two consequences run through everything in this module: +//! +//! - **A slot carries one message.** Several distinct `AttestationData` at one +//! slot have no representation inside a single aggregate; that is +//! [`ConflictingMessages`]. +//! - **The binding travels out of band.** The without-pubkeys wire form carries +//! neither the keys nor the `(slot, message)` pairs, so a decode has to +//! rebuild the whole signer set from the caller's own view of it (a +//! [`SignerSet`] per claim). A set or binding other than the one aggregated +//! decodes fine and fails verification, since the proof binds a hash of the +//! structure: the `(message, slot)` check is inside the SNARK now, not a +//! cheap field compare ahead of it. use ethlambda_types::{block::ByteList512KiB, primitives::H256}; -use crate::signature::{ - LeanSigPublicKey, LeanSigSignature, ValidatorPublicKey, ValidatorSignature, -}; -use lean_multisig::{ - MultiMessageAggregateSignature as LMType2, SingleMessageAggregateSignature as LMType1, - aggregate_single_message_signatures, merge_single_message_aggregates, setup_prover, - setup_prover_without_arena, setup_verifier, split_multi_message_aggregate, - verify_multi_message_aggregate, verify_single_message_aggregate, -}; +use crate::signature::{ValidatorPublicKey, ValidatorSignature}; +use leanvm::{AggregateSignature, WireKeys, XmssGroup, aggregate, xmss}; use std::sync::{Mutex, MutexGuard}; use thiserror::Error; use tracing::error; @@ -32,25 +46,34 @@ pub mod shadow_cost; /// log(1/rate) for the WHIR commitment scheme used inside the aggregation prover. const LOG_INV_RATE: usize = 2; +/// Raw XMSS input as [`aggregate`] takes it: the key, the epoch it signed at, +/// the message, and the signature. +type RawXmss = Vec<( + xmss::XmssPublicKey, + xmss::Epoch, + xmss::Message, + xmss::XmssSignature, +)>; + /// Initializes the leanVM backend. Call once at startup, before any other function here. /// /// Everything downstream assumes this has run: proving panics without the aggregation -/// bytecode, and decoding a stored proof needs it too (a Type-2 decode rebuilds the -/// bytecode claim and returns `None` without it, which surfaces as a bogus -/// `DeserializationFailed`). Doing it up front keeps the cost off the first duty. +/// bytecode, and decoding a stored proof needs it too, since a decode rebuilds the +/// bytecode claim and fails without it, which surfaces as a bogus +/// `DeserializationFailed`. Doing it up front keeps the cost off the first duty. /// -/// `use_arena` picks the prover's allocator. leanVM's arena is faster but never returns -/// pages to the OS, so a node's RSS ratchets to its allocation high-water mark and stays -/// there; the system allocator trades throughput for bounded memory. +/// `use_arena` picks the prover's allocator. leanVM's arena recycles the prover's large +/// transient buffers across proofs instead of re-faulting them, so its pages stay +/// resident for the lifetime of the node; the system allocator trades throughput for +/// memory that comes back. /// /// Idempotent: every step is `Once`/`OnceLock` guarded. pub fn init_leanvm(use_arena: bool) { if use_arena { - setup_prover(); + leanvm::setup_prover(); } else { - setup_prover_without_arena(); + leanvm::setup_prover_without_arena(); } - setup_verifier(); } /// Claims the exclusive right to prove. @@ -69,6 +92,43 @@ fn acquire_prover() -> MutexGuard<'static, ()> { }) } +/// One claim inside an aggregate: the `(message, slot)` a set of validators +/// signed, and the keys of those validators. +/// +/// Both halves are needed to decode a stored proof, and neither is on the wire: +/// see the module docs. +#[derive(Clone, Debug)] +pub struct SignerSet { + /// The signed message: an `AttestationData` root, or a block root. + pub message: H256, + /// The slot the signature was made at, which is the XMSS epoch. + pub slot: u32, + /// The validators the claim binds, in any order and with duplicates allowed; + /// [`wire_keys`] sorts and deduplicates as leanVM's signer set requires. + pub public_keys: Vec, +} + +impl SignerSet { + pub fn new(message: H256, slot: u32, public_keys: Vec) -> Self { + Self { + message, + slot, + public_keys, + } + } +} + +/// Two claims at one slot carrying different messages. +/// +/// leanVM keys an aggregate's XMSS groups by epoch, so inside one proof the +/// message is a function of the slot. Nothing in this crate can work around it: +/// the group holds a single message, so the second claim has nowhere to go. +#[derive(Debug, Clone, Copy, Error)] +#[error("slot {slot} carries two different messages in one aggregate")] +pub struct ConflictingMessages { + pub slot: u32, +} + /// Error type for signature aggregation operations. #[derive(Debug, Error)] pub enum AggregationError { @@ -90,16 +150,13 @@ pub enum AggregationError { #[error("need at least 2 children for recursive aggregation, got {0}")] InsufficientChildren(usize), - #[error("component count ({components}) does not match pubkey-set count ({pubkey_sets})")] - ComponentPubkeyMismatch { - components: usize, - pubkey_sets: usize, - }, + #[error(transparent)] + ConflictingMessages(#[from] ConflictingMessages), - #[error("split-by-message target not found in type-2 components")] + #[error("split-by-message target not found in the aggregate's signer set")] UnknownMessage, - #[error("split-by-message target matched multiple components")] + #[error("split-by-message target matched several slots")] MultipleMessages, #[error("prover failure: {0}")] @@ -109,72 +166,115 @@ pub enum AggregationError { /// Error type for signature verification operations. #[derive(Debug, Error)] pub enum VerificationError { - #[error("public key conversion failed at index {index}: {reason}")] - PublicKeyConversion { index: usize, reason: String }, - #[error("proof deserialization failed")] DeserializationFailed, #[error("verification failed: {0}")] VerificationFailed(String), - #[error( - "(message, slot) mismatch: proof binds {got_slot}/{got_msg:?}, expected {expected_slot}/{expected_msg:?}" - )] - BindingMismatch { - expected_msg: H256, - expected_slot: u32, - got_msg: H256, - got_slot: u32, - }, - - #[error("component count ({components}) does not match pubkey-set count ({pubkey_sets})")] - ComponentPubkeyMismatch { - components: usize, - pubkey_sets: usize, - }, - - #[error("type-2 binds {got} components but {expected} were expected")] - Type2ComponentCountMismatch { expected: usize, got: usize }, + #[error(transparent)] + ConflictingMessages(#[from] ConflictingMessages), } // ===================================================================== // Helpers // ===================================================================== -fn into_lean_pubkeys(pubkeys: Vec) -> Vec { - pubkeys +/// The signer set leanVM binds, built from the caller's view of the claims. +/// +/// One group per slot, holding that slot's message and its keys strictly sorted +/// and deduplicated, with the groups themselves sorted by slot. Claims sharing a +/// slot merge into one group, so their keys are unioned; claims sharing a slot +/// under different messages are [`ConflictingMessages`]. +/// +/// Getting this structure wrong is not caught at decode: it changes the digest +/// the proof is checked against, so it surfaces as a verification failure. +fn wire_keys(components: &[SignerSet]) -> Result { + let mut groups: Vec = Vec::with_capacity(components.len()); + for component in components { + let keys = component.public_keys.iter().map(|pk| pk.as_inner().clone()); + match groups.iter_mut().find(|(slot, ..)| *slot == component.slot) { + Some((_, message, group_keys)) => { + if *message != component.message.0 { + return Err(ConflictingMessages { + slot: component.slot, + }); + } + group_keys.extend(keys); + } + None => groups.push((component.slot, component.message.0, keys.collect())), + } + } + for (_, _, keys) in &mut groups { + sort_dedup(keys); + } + groups.sort_unstable_by_key(|(slot, ..)| *slot); + Ok((groups, Vec::new())) +} + +/// [`wire_keys`] for a single claim, which cannot conflict with itself. +fn one_group(message: &H256, slot: u32, public_keys: &[ValidatorPublicKey]) -> WireKeys { + let mut keys: Vec<_> = public_keys.iter().map(|pk| pk.as_inner().clone()).collect(); + sort_dedup(&mut keys); + (vec![(slot, message.0, keys)], Vec::new()) +} + +/// A group's keys as leanVM's signer set requires them: strictly sorted, so the +/// list's length is a count of distinct claims and no key is covered twice. +/// +/// The aggregator does the same to its raw inputs, so a verifier that skips this +/// hands over a different digest and fails a proof that is in fact valid. +fn sort_dedup(keys: &mut Vec) { + keys.sort_unstable(); + keys.dedup(); +} + +/// Pair raw XMSS keys with their signatures for [`aggregate`], all at one +/// `(message, slot)`. +fn raw_xmss_inputs( + public_keys: Vec, + signatures: Vec, + message: &H256, + slot: u32, +) -> RawXmss { + public_keys .into_iter() - .map(ValidatorPublicKey::into_inner) + .zip(signatures) + .map(|(pk, sig)| (pk.into_inner(), slot, message.0, sig.into_inner())) .collect() } -/// Decompress a stored Type-1 proof (without-pubkeys form) into a native -/// `SingleMessageAggregateSignature` by attaching the resolved validator pubkeys. -/// -/// leanVM sorts and de-duplicates the attached set itself, so the order here does -/// not matter. A set other than the one aggregated attaches fine but fails -/// verification: the proof binds a hash of the set. -fn decompress_type1( - pubkeys: Vec, - proof_bytes: &ByteList512KiB, - index: usize, -) -> Result { - let lean_pks = into_lean_pubkeys(pubkeys); - LMType1::from_bytes_without_pubkeys(proof_bytes.iter().as_slice(), lean_pks) - .ok_or(AggregationError::ChildDeserializationFailed(index)) +/// Decompress the stored Type-1 children of a recursive aggregation, all of +/// which share one `(message, slot)`. +fn decompress_children( + children: Vec<(Vec, ByteList512KiB)>, + message: &H256, + slot: u32, +) -> Result, AggregationError> { + children + .into_iter() + .enumerate() + .map(|(index, (pubkeys, proof_bytes))| { + let keys = one_group(message, slot, &pubkeys); + AggregateSignature::from_bytes_without_pubkeys(proof_bytes.iter().as_slice(), keys) + .map_err(|_| AggregationError::ChildDeserializationFailed(index)) + }) + .collect() } -fn compress_type1_to_byte_list(sig: &LMType1) -> Result { +fn compress_to_byte_list(sig: &AggregateSignature) -> Result { let serialized = sig.to_bytes_without_pubkeys(); let len = serialized.len(); ByteList512KiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) } -fn compress_type2_to_byte_list(sig: &LMType2) -> Result { - let serialized = sig.to_bytes_without_pubkeys(); - let len = serialized.len(); - ByteList512KiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) +/// leanVM's aggregation errors, kept as their own text. +/// +/// They cover both proving failures and malformed requests (a slot carrying two +/// messages, a child that does not verify, too many children); the message says +/// which, and no caller here branches on the distinction. +fn aggregation_failed(err: leanvm::AggregationError) -> AggregationError { + AggregationError::ProverFailure(err.to_string()) } // ===================================================================== @@ -183,15 +283,13 @@ fn compress_type2_to_byte_list(sig: &LMType2) -> Result, signatures: Vec, @@ -221,19 +319,13 @@ pub fn aggregate_signatures( return Ok(dummy); } - let raw_xmss: Vec<(LeanSigPublicKey, LeanSigSignature)> = public_keys - .into_iter() - .zip(signatures) - .map(|(pk, sig)| (pk.into_inner(), sig.into_inner())) - .collect(); + let raw_xmss = raw_xmss_inputs(public_keys, signatures, message, slot); let _permit = acquire_prover(); - let proof = aggregate_single_message_signatures(&[], raw_xmss, message.0, slot, LOG_INV_RATE) - .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; + let proof = aggregate(&[], raw_xmss, vec![], None, LOG_INV_RATE).map_err(aggregation_failed)?; - let result = compress_type1_to_byte_list(&proof)?; - Ok(result) + compress_to_byte_list(&proof) } /// Aggregate both existing Type-1 proofs (children) and raw XMSS signatures. @@ -276,31 +368,15 @@ pub fn aggregate_mixed( return Ok(dummy); } - let children_native: Vec = children - .into_iter() - .enumerate() - .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) - .collect::>()?; - - let raw_xmss: Vec<(LeanSigPublicKey, LeanSigSignature)> = raw_public_keys - .into_iter() - .zip(raw_signatures) - .map(|(pk, sig)| (pk.into_inner(), sig.into_inner())) - .collect(); + let children_native = decompress_children(children, message, slot)?; + let raw_xmss = raw_xmss_inputs(raw_public_keys, raw_signatures, message, slot); let _permit = acquire_prover(); - let proof = aggregate_single_message_signatures( - &children_native, - raw_xmss, - message.0, - slot, - LOG_INV_RATE, - ) - .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; - - let result = compress_type1_to_byte_list(&proof)?; - Ok(result) + let proof = aggregate(&children_native, raw_xmss, vec![], None, LOG_INV_RATE) + .map_err(aggregation_failed)?; + + compress_to_byte_list(&proof) } /// Recursively aggregate two or more already-aggregated Type-1 proofs into one. @@ -330,33 +406,24 @@ pub fn aggregate_proofs( return Ok(dummy); } - let children_native: Vec = children - .into_iter() - .enumerate() - .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) - .collect::>()?; + let children_native = decompress_children(children, message, slot)?; let _permit = acquire_prover(); - let proof = aggregate_single_message_signatures( - &children_native, - vec![], - message.0, - slot, - LOG_INV_RATE, - ) - .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; - - let result = compress_type1_to_byte_list(&proof)?; - Ok(result) + let proof = aggregate(&children_native, vec![], vec![], None, LOG_INV_RATE) + .map_err(aggregation_failed)?; + + compress_to_byte_list(&proof) } /// Verify a Type-1 aggregated signature proof. /// /// Cryptographically verifies that every `public_key` signed `message` at `slot`. /// -/// The verifier checks the bound `(message, slot)` matches what the caller -/// expects, defending against proofs reused from other binding contexts. +/// The binding is checked by being supplied: `(message, slot)` and the key set +/// go into the signer set the proof's digest commits to, so a proof reused from +/// another binding context fails inside the SNARK verifier rather than at a +/// field compare ahead of it. pub fn verify_aggregated_signature( proof_data: &ByteList512KiB, public_keys: Vec, @@ -372,22 +439,12 @@ pub fn verify_aggregated_signature( return Ok(()); } - let lean_pubkeys = into_lean_pubkeys(public_keys); - let sig = LMType1::from_bytes_without_pubkeys(proof_data.iter().as_slice(), lean_pubkeys) - .ok_or(VerificationError::DeserializationFailed)?; - - if sig.info.core.message != message.0 || sig.info.core.slot != slot { - return Err(VerificationError::BindingMismatch { - expected_msg: *message, - expected_slot: slot, - got_msg: H256(sig.info.core.message), - got_slot: sig.info.core.slot, - }); - } + let keys = one_group(message, slot, &public_keys); + let sig = AggregateSignature::from_bytes_without_pubkeys(proof_data.iter().as_slice(), keys) + .map_err(|_| VerificationError::DeserializationFailed)?; - verify_single_message_aggregate(&sig) - .map_err(|err| VerificationError::VerificationFailed(format!("{err:?}")))?; - Ok(()) + sig.verify() + .map_err(|err| VerificationError::VerificationFailed(format!("{err:?}"))) } // ===================================================================== @@ -396,14 +453,17 @@ pub fn verify_aggregated_signature( /// Merge many independent Type-1 multi-signatures into a single Type-2 proof. /// -/// Each input is `(participant_pubkeys, type_1_proof_bytes)` where the bytes -/// are the `to_bytes_without_pubkeys()` form of a `SingleMessageAggregateSignature`. +/// Each input is `(claim, type_1_proof_bytes)` where the bytes are the +/// `to_bytes_without_pubkeys()` form of an aggregate over exactly that claim. /// -/// The returned blob is the `to_bytes_without_pubkeys()` form of the resulting -/// `MultiMessageAggregateSignature`. A verifier decoding it back needs the per-component -/// pubkey sets in the same order. +/// The returned blob is the `to_bytes_without_pubkeys()` form of the merged +/// aggregate, whose signer set is the union of the claims grouped by slot. A +/// verifier decoding it back needs the same claims, in any order. +/// +/// Two claims at one slot under different messages cannot be merged at all: +/// leanVM rejects the pair rather than producing a proof (see the module docs). pub fn merge_type_1s_into_type_2( - type_1s: Vec<(Vec, ByteList512KiB)>, + type_1s: Vec<(SignerSet, ByteList512KiB)>, ) -> Result { if type_1s.is_empty() { return Err(AggregationError::EmptyInput); @@ -424,88 +484,60 @@ pub fn merge_type_1s_into_type_2( return Ok(dummy); } - let type_1s_native: Vec = type_1s - .into_iter() + let type_1s_native: Vec = type_1s + .iter() .enumerate() - .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) + .map(|(index, (claim, proof_bytes))| { + let keys = one_group(&claim.message, claim.slot, &claim.public_keys); + AggregateSignature::from_bytes_without_pubkeys(proof_bytes.iter().as_slice(), keys) + .map_err(|_| AggregationError::ChildDeserializationFailed(index)) + }) .collect::>()?; let _permit = acquire_prover(); - let merged = merge_single_message_aggregates(type_1s_native, LOG_INV_RATE) - .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; + let merged = aggregate(&type_1s_native, vec![], vec![], None, LOG_INV_RATE) + .map_err(aggregation_failed)?; - let result = compress_type2_to_byte_list(&merged)?; - Ok(result) + compress_to_byte_list(&merged) } -/// Verify a Type-2 merged proof against the per-component expected bindings. +/// Verify a Type-2 merged proof against the claims the caller expects it to carry. /// -/// The verifier re-derives each component's `(message, slot, pubkeys)` from the -/// caller-supplied lists, checks they match what the proof binds, and then runs -/// the inner SNARK verifier. +/// The claims are rebuilt from the block body, grouped by slot into the signer +/// set the proof's digest commits to, so a proof over other keys, other +/// messages or other slots fails the SNARK verifier. pub fn verify_type_2_signature( proof_data: &[u8], - pubkeys_per_component: Vec>, - expected_bindings: &[(H256, u32)], + components: &[SignerSet], ) -> Result<(), VerificationError> { - if expected_bindings.len() != pubkeys_per_component.len() { - return Err(VerificationError::ComponentPubkeyMismatch { - components: expected_bindings.len(), - pubkey_sets: pubkeys_per_component.len(), - }); - } - #[cfg(feature = "shadow-integration")] if crate::shadow_cost::fake_xmss() { return Ok(()); } - let pubkeys_per_info: Vec> = pubkeys_per_component - .into_iter() - .map(into_lean_pubkeys) - .collect(); - - let sig = LMType2::from_bytes_without_pubkeys(proof_data, pubkeys_per_info) - .ok_or(VerificationError::DeserializationFailed)?; - - if sig.info.len() != expected_bindings.len() { - return Err(VerificationError::Type2ComponentCountMismatch { - expected: expected_bindings.len(), - got: sig.info.len(), - }); - } - - for ((expected_msg, expected_slot), info) in expected_bindings.iter().zip(sig.info.iter()) { - if info.core.message != expected_msg.0 || info.core.slot != *expected_slot { - return Err(VerificationError::BindingMismatch { - expected_msg: *expected_msg, - expected_slot: *expected_slot, - got_msg: H256(info.core.message), - got_slot: info.core.slot, - }); - } - } + let keys = wire_keys(components)?; + let sig = AggregateSignature::from_bytes_without_pubkeys(proof_data, keys) + .map_err(|_| VerificationError::DeserializationFailed)?; - verify_multi_message_aggregate(&sig) - .map_err(|err| VerificationError::VerificationFailed(format!("{err:?}")))?; - Ok(()) + sig.verify() + .map_err(|err| VerificationError::VerificationFailed(format!("{err:?}"))) } -/// Split (disaggregate) a Type-2 merged proof into a single Type-1 proof for -/// the component bound to `message`. Generates a fresh SNARK; expensive. +/// Narrow a Type-2 merged proof down to the single claim bound to `message`, +/// yielding a Type-1 for it. Generates a fresh SNARK; expensive. /// -/// Mirrors leanSpec PR #717 `split_multi_message_aggregate_by_message`: the caller -/// supplies the expected message (an attestation data root or the block -/// root) and the wrapper locates the unique matching component inside the -/// decompressed proof. Returns the `to_bytes_without_pubkeys()` form of the -/// resulting Type-1. +/// Mirrors leanSpec PR #717 `split_multi_message_aggregate_by_message`: the +/// caller supplies the expected message (an attestation data root or the block +/// root) and the wrapper narrows the aggregate to the unique slot carrying it. +/// leanVM does this by re-aggregating the parent with a declaration of what to +/// keep, so the result is a proof over that group alone. /// -/// `pubkeys_per_component` gives one signer set per component, in the proof's -/// component order; they are not on the wire, so decoding needs them. +/// `components` gives every claim the parent carries, in any order; they are not +/// on the wire, so decoding needs them all even though only one survives. pub fn split_type_2_by_message( proof_data: &[u8], - pubkeys_per_component: Vec>, + components: &[SignerSet], message: &H256, ) -> Result { #[cfg(feature = "shadow-integration")] @@ -516,60 +548,56 @@ pub fn split_type_2_by_message( )); } - let pubkeys_per_info: Vec> = pubkeys_per_component - .into_iter() - .map(into_lean_pubkeys) - .collect(); - - let type_2 = LMType2::from_bytes_without_pubkeys(proof_data, pubkeys_per_info) - .ok_or(AggregationError::DeserializationFailed)?; + let keys = wire_keys(components)?; + let type_2 = AggregateSignature::from_bytes_without_pubkeys(proof_data, keys) + .map_err(|_| AggregationError::DeserializationFailed)?; - let matches: Vec = type_2 - .info + // A slot carries one message, so a message that appears at all appears in + // exactly one group unless two slots signed the very same bytes. + let mut matches = type_2 + .xmss_signers() .iter() - .enumerate() - .filter_map(|(i, info)| (info.core.message == message.0).then_some(i)) - .collect(); - let index = match matches.as_slice() { - [i] => *i, - [] => return Err(AggregationError::UnknownMessage), - _ => return Err(AggregationError::MultipleMessages), + .filter(|(_, group_message, _)| *group_message == message.0); + let group = match (matches.next(), matches.next()) { + (Some(group), None) => group.clone(), + (None, _) => return Err(AggregationError::UnknownMessage), + (Some(_), Some(_)) => return Err(AggregationError::MultipleMessages), }; + let declare: WireKeys = (vec![group], Vec::new()); + let _permit = acquire_prover(); - let component = split_multi_message_aggregate(type_2, index, LOG_INV_RATE) - .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; + let component = aggregate(&[type_2], vec![], vec![], Some(&declare), LOG_INV_RATE) + .map_err(aggregation_failed)?; - compress_type1_to_byte_list(&component) + compress_to_byte_list(&component) } #[cfg(test)] mod tests { use super::*; - use lean_multisig::{xmss_key_gen_from_seed, xmss_sign}; - use ssz::Encode as _; + use leanvm::xmss::{Encode as _, key_gen_from_seed}; /// Generate a test keypair and sign a message. /// /// Note: This is slow because XMSS key generation is computationally expensive. fn generate_keypair_and_sign( seed: u64, - activation_epoch: u32, - signing_epoch: u32, + first_slot: u32, + signing_slot: u32, message: &H256, ) -> (ValidatorPublicKey, ValidatorSignature) { let mut seed_bytes = [0u8; 32]; seed_bytes[..8].copy_from_slice(&seed.to_le_bytes()); - // Small active range (starting at the activation epoch and covering the - // signing slot) for fast key generation. - let num_active_slots = 64u64; - let (pk, sk) = - xmss_key_gen_from_seed(seed_bytes, activation_epoch as u64, num_active_slots) - .expect("valid activation range"); + // Small slot range (starting at `first_slot` and covering the signing + // slot) for fast key generation. + let (sk, pk) = + key_gen_from_seed(seed_bytes, first_slot, first_slot + 63).expect("valid slot range"); - let sig = xmss_sign(&sk, signing_epoch, &message.0).expect("sign"); + let sig = + xmss::sign(&mut leanvm::rand::rng(), &sk, &message.0, signing_slot).expect("sign"); // Convert to ethlambda types via SSZ wire bytes. let validator_pk = ValidatorPublicKey::from_bytes(&pk.as_ssz_bytes()).unwrap(); @@ -584,6 +612,11 @@ mod tests { init_leanvm(false); } + /// A claim over one validator, the shape every Type-2 component takes here. + fn claim(message: H256, slot: u32, pk: &ValidatorPublicKey) -> SignerSet { + SignerSet::new(message, slot, vec![pk.clone()]) + } + #[test] #[ignore = "slow: compiles the leanVM aggregation bytecode (needs a release-sized stack)"] fn test_setup_is_idempotent() { @@ -599,15 +632,60 @@ mod tests { drop(acquire_prover()); } + /// The claim list a decode rebuilds has to match what was aggregated, and + /// the shapes leanVM's signer set requires are this wrapper's job: one + /// group per slot, keys sorted and deduplicated, groups sorted by slot. + #[test] + fn wire_keys_groups_by_slot_and_sorts() { + let pk = |byte: u8| { + ValidatorPublicKey::from_bytes(&[byte; 32]).expect("any 32 bytes decode as a pubkey") + }; + let msg_a = H256::from([0xaau8; 32]); + let msg_b = H256::from([0xbbu8; 32]); + + // Slot 9 twice (keys unioned) and slot 4 once, handed over out of order. + let components = vec![ + SignerSet::new(msg_b, 9, vec![pk(3), pk(1)]), + SignerSet::new(msg_a, 4, vec![pk(2)]), + SignerSet::new(msg_b, 9, vec![pk(1), pk(2)]), + ]; + let (groups, sphincs) = wire_keys(&components).expect("one message per slot"); + + assert!(sphincs.is_empty(), "ethlambda signs XMSS only"); + let slots: Vec = groups.iter().map(|(slot, ..)| *slot).collect(); + assert_eq!(slots, vec![4, 9], "groups sorted by slot"); + assert_eq!(groups[0].1, msg_a.0); + assert_eq!(groups[1].1, msg_b.0); + assert_eq!(groups[0].2.len(), 1); + // Keys 1, 2, 3 unioned across the two slot-9 claims, deduplicated. + assert_eq!(groups[1].2.len(), 3); + assert!( + groups[1].2.windows(2).all(|w| w[0] < w[1]), + "keys strictly sorted" + ); + } + + /// Distinct `AttestationData` at one slot cannot share an aggregate, so the + /// wrapper says so instead of handing leanVM a set it cannot represent. + #[test] + fn wire_keys_rejects_two_messages_at_one_slot() { + let pk = ValidatorPublicKey::from_bytes(&[7u8; 32]).expect("32 bytes decode"); + let components = vec![ + SignerSet::new(H256::from([1u8; 32]), 6, vec![pk.clone()]), + SignerSet::new(H256::from([2u8; 32]), 6, vec![pk]), + ]; + let err = wire_keys(&components).expect_err("one slot, two messages"); + assert_eq!(err.slot, 6); + } + #[test] #[ignore = "too slow"] fn test_aggregate_single_signature() { init(); let message = H256::from([42u8; 32]); let slot = 10u32; - let activation_epoch = 5u32; - let (pk, sig) = generate_keypair_and_sign(1, activation_epoch, slot, &message); + let (pk, sig) = generate_keypair_and_sign(1, 5, slot, &message); let result = aggregate_signatures(vec![pk.clone()], vec![sig], &message, slot); assert!(result.is_ok(), "Aggregation failed: {:?}", result.err()); @@ -631,18 +709,18 @@ mod tests { let message = H256::from([42u8; 32]); let slot = 15u32; - // Generate 3 keypairs with different activation epochs + // Generate 3 keypairs whose ranges all cover the signing slot. let configs = vec![ - (1u64, 5u32), // seed, activation_epoch - (2u64, 8u32), // seed, activation_epoch - (3u64, 10u32), // seed, activation_epoch + (1u64, 5u32), // seed, first signable slot + (2u64, 8u32), // seed, first signable slot + (3u64, 10u32), // seed, first signable slot ]; let mut pubkeys = Vec::new(); let mut signatures = Vec::new(); - for (seed, activation_epoch) in configs { - let (pk, sig) = generate_keypair_and_sign(seed, activation_epoch, slot, &message); + for (seed, first_slot) in configs { + let (pk, sig) = generate_keypair_and_sign(seed, first_slot, slot, &message); pubkeys.push(pk); signatures.push(sig); } @@ -668,9 +746,8 @@ mod tests { let message = H256::from([42u8; 32]); let wrong_message = H256::from([43u8; 32]); let slot = 10u32; - let activation_epoch = 5u32; - let (pk, sig) = generate_keypair_and_sign(1, activation_epoch, slot, &message); + let (pk, sig) = generate_keypair_and_sign(1, 5, slot, &message); let proof_data = aggregate_signatures(vec![pk.clone()], vec![sig], &message, slot).unwrap(); @@ -690,9 +767,8 @@ mod tests { let message = H256::from([42u8; 32]); let slot = 10u32; let wrong_slot = 11u32; - let activation_epoch = 5u32; - let (pk, sig) = generate_keypair_and_sign(1, activation_epoch, slot, &message); + let (pk, sig) = generate_keypair_and_sign(1, 5, slot, &message); let proof_data = aggregate_signatures(vec![pk.clone()], vec![sig], &message, slot).unwrap(); @@ -747,25 +823,18 @@ mod tests { let pa = aggregate_signatures(vec![pk_a.clone()], vec![sig_a], &msg_a, slot_a).unwrap(); let pb = aggregate_signatures(vec![pk_b.clone()], vec![sig_b], &msg_b, slot_b).unwrap(); - let merged = - merge_type_1s_into_type_2(vec![(vec![pk_a.clone()], pa), (vec![pk_b.clone()], pb)]) - .expect("merge"); - - verify_type_2_signature( - merged.iter().as_slice(), - vec![vec![pk_a.clone()], vec![pk_b.clone()]], - &[(msg_a, slot_a), (msg_b, slot_b)], - ) - .expect("verify type-2"); - - let split = split_type_2_by_message( - merged.iter().as_slice(), - vec![vec![pk_a.clone()], vec![pk_b.clone()]], - &msg_a, - ) - .expect("split"); - - verify_aggregated_signature(&split, vec![pk_a.clone()], &msg_a, slot_a) - .expect("verify split"); + let components = vec![claim(msg_a, slot_a, &pk_a), claim(msg_b, slot_b, &pk_b)]; + let merged = merge_type_1s_into_type_2(vec![ + (components[0].clone(), pa), + (components[1].clone(), pb), + ]) + .expect("merge"); + + verify_type_2_signature(merged.iter().as_slice(), &components).expect("verify type-2"); + + let split = + split_type_2_by_message(merged.iter().as_slice(), &components, &msg_a).expect("split"); + + verify_aggregated_signature(&split, vec![pk_a], &msg_a, slot_a).expect("verify split"); } } diff --git a/crates/common/crypto/src/signature.rs b/crates/common/crypto/src/signature.rs index f4e5820b..ba86e48b 100644 --- a/crates/common/crypto/src/signature.rs +++ b/crates/common/crypto/src/signature.rs @@ -1,13 +1,12 @@ //! Validator XMSS signatures, public/secret keys, and the leanVM-backed //! primitives behind them. -use std::ops::Range; +use std::ops::RangeInclusive; use ethlambda_types::{attestation::SIGNATURE_SIZE, primitives::H256, state::PUBLIC_KEY_SIZE}; -use ssz::{Decode, Encode}; -use xmss::{ - PUB_KEY_SSZ_LEN, SIGNATURE_SSZ_LEN, XmssPublicKey, XmssSecretKey, XmssSignature, - XmssSignatureError, xmss_sign, xmss_verify, +use leanvm::xmss::{ + self, Decode, Encode, PUB_KEY_SSZ_LEN, SIGNATURE_SSZ_LEN, XmssPublicKey, XmssSecretKey, + XmssSignError, XmssSignature, }; // `ethlambda-types` hardcodes the XMSS wire sizes so it can stay free of the @@ -27,17 +26,6 @@ use xmss::{ const _: [(); SIGNATURE_SIZE] = [(); SIGNATURE_SSZ_LEN]; const _: [(); PUBLIC_KEY_SIZE] = [(); PUB_KEY_SSZ_LEN]; -/// The public key type from leanVM's xmss crate. -pub type LeanSigPublicKey = XmssPublicKey; - -/// The signature type from leanVM's xmss crate. -pub type LeanSigSignature = XmssSignature; - -/// The secret key type from leanVM's xmss crate. -pub type LeanSigSecretKey = XmssSecretKey; - -pub type Signature = LeanSigSignature; - /// Error returned when parsing signature or key bytes fails. #[derive(Debug, Clone, thiserror::Error)] #[error("signature parse error: {0}")] @@ -45,13 +33,13 @@ pub struct SignatureParseError(pub String); #[derive(Clone)] pub struct ValidatorSignature { - inner: LeanSigSignature, + inner: XmssSignature, } impl ValidatorSignature { /// Parse from the SSZ-encoded wire form (`SIGNATURE_SIZE` bytes). pub fn from_bytes(bytes: &[u8]) -> Result { - let sig = LeanSigSignature::from_ssz_bytes(bytes) + let sig = XmssSignature::from_ssz_bytes(bytes) .map_err(|e| SignatureParseError(format!("{e:?}")))?; Ok(Self { inner: sig }) } @@ -62,23 +50,25 @@ impl ValidatorSignature { } pub fn is_valid(&self, pubkey: &ValidatorPublicKey, slot: u32, message: &H256) -> bool { - xmss_verify(&pubkey.inner, slot, &message.0, &self.inner).is_ok() + xmss::verify(&pubkey.inner, &message.0, &self.inner, slot).is_ok() } - pub fn into_inner(self) -> LeanSigSignature { + pub fn into_inner(self) -> XmssSignature { self.inner } } -#[derive(Clone)] +// `Debug` only on the public key: the signature and the secret key carry +// material that must not reach a log line by accident. +#[derive(Clone, Debug)] pub struct ValidatorPublicKey { - inner: LeanSigPublicKey, + inner: XmssPublicKey, } impl ValidatorPublicKey { /// Parse from the SSZ-encoded wire form (`PUBLIC_KEY_SIZE` bytes). pub fn from_bytes(bytes: &[u8]) -> Result { - let pk = LeanSigPublicKey::from_ssz_bytes(bytes) + let pk = XmssPublicKey::from_ssz_bytes(bytes) .map_err(|e| SignatureParseError(format!("{e:?}")))?; Ok(Self { inner: pk }) } @@ -88,23 +78,28 @@ impl ValidatorPublicKey { self.inner.as_ssz_bytes() } - pub fn into_inner(self) -> LeanSigPublicKey { + pub fn into_inner(self) -> XmssPublicKey { self.inner } + + pub fn as_inner(&self) -> &XmssPublicKey { + &self.inner + } } /// Validator private key for signing attestations and blocks. pub struct ValidatorSecretKey { - inner: LeanSigSecretKey, + inner: XmssSecretKey, } impl ValidatorSecretKey { /// Parse from the postcard-encoded key file produced by the genesis generator. /// - /// leanVM's `XmssSecretKey` uses serde (postcard) rather than the SSZ-style - /// `Serializable` of the old leanSig scheme. + /// leanVM's `XmssSecretKey` carries serde rather than an SSZ codec: the + /// secret key never appears in consensus data, so only the two wire types + /// get one. pub fn from_bytes(bytes: &[u8]) -> Result { - let sk = postcard::from_bytes::(bytes) + let sk = postcard::from_bytes::(bytes) .map_err(|e| SignatureParseError(format!("{e:?}")))?; Ok(Self { inner: sk }) } @@ -124,68 +119,63 @@ impl ValidatorSecretKey { /// Sign a message at `slot`. /// /// The slot indexes the one-time XMSS leaf; never sign two different - /// messages at the same slot. - pub fn sign( - &self, - slot: u32, - message: &H256, - ) -> Result { - let sig = xmss_sign(&self.inner, slot, &message.0)?; + /// messages at the same slot. leanVM draws the signature randomness itself, + /// so even re-signing the same message at one slot leaks key material. + pub fn sign(&self, slot: u32, message: &H256) -> Result { + let sig = xmss::sign(&mut leanvm::rand::rng(), &self.inner, &message.0, slot)?; Ok(ValidatorSignature { inner: sig }) } - /// Returns true if the key can sign at the given slot. + /// Whether the key covers `slot` at all. /// - /// leanVM's xmss keys cover a fixed activation range (fixed at key - /// generation); there is no sliding preparation window as in the old - /// leanSig scheme. - pub fn is_prepared_for(&self, slot: u32) -> bool { - self.inner.activation_slots().contains(&slot) + /// The range is fixed at key generation; a slot outside it can never be + /// signed, however long the node waits. + pub fn can_sign_at(&self, slot: u32) -> bool { + self.inner.epoch_range().contains(&slot) } - /// The half-open slot range this key can sign for. - pub fn get_prepared_interval(&self) -> Range { - let range = self.inner.activation_slots(); - (*range.start() as u64)..(*range.end() as u64 + 1) + /// The inclusive slot range this key can sign for. + pub fn signable_slots(&self) -> RangeInclusive { + self.inner.epoch_range() } - /// No-op retained for API compatibility. + /// Warm the signing cache for `slot`, so [`Self::sign`] there does not pay + /// for rebuilding the bottom Merkle subtree. /// - /// The old leanSig scheme advanced a two-bottom-tree preparation window; - /// leanVM's xmss keys have a fixed activation range and warm their signing - /// cache on demand inside `sign`, so there is nothing to advance. - pub fn advance_preparation(&mut self) {} + /// The key holds one cached subtree, so this is worth calling only for the + /// slot about to be signed, and it is pure latency shifting: a miss inside + /// `sign` rebuilds the same subtree. Errors only when `slot` is outside + /// [`Self::signable_slots`]. + pub fn prepare(&self, slot: u32) -> Result<(), XmssSignError> { + self.inner.prepare(slot) + } } #[cfg(test)] mod tests { use super::*; - use xmss::xmss_key_gen_from_seed; - - /// Generate a validator key pair over a small activation range. - fn generate_key( - seed: [u8; 32], - activation_slot: u64, - num_active_slots: u64, - ) -> ValidatorSecretKey { - let (_pk, sk) = xmss_key_gen_from_seed(seed, activation_slot, num_active_slots) - .expect("valid activation range"); + + /// Generate a validator key pair over a small slot range (fast key generation). + fn generate_key(seed: [u8; 32], first_slot: u32, last_slot: u32) -> ValidatorSecretKey { + let (sk, _pk) = + xmss::key_gen_from_seed(seed, first_slot, last_slot).expect("valid slot range"); ValidatorSecretKey { inner: sk } } #[test] #[ignore = "slow: XMSS key generation and signing"] fn sign_verify_round_trip() { - let sk = generate_key([7u8; 32], 0, 64); + let sk = generate_key([7u8; 32], 0, 63); let pk = sk.public_key(); - assert!(sk.is_prepared_for(0)); - assert!(sk.is_prepared_for(63)); - assert!(!sk.is_prepared_for(64)); - assert_eq!(sk.get_prepared_interval(), 0..64); + assert!(sk.can_sign_at(0)); + assert!(sk.can_sign_at(63)); + assert!(!sk.can_sign_at(64)); + assert_eq!(sk.signable_slots(), 0..=63); let message = H256::from([42u8; 32]); let slot = 10u32; + sk.prepare(slot).expect("slot in range"); let sig = sk.sign(slot, &message).expect("sign"); assert!(sig.is_valid(&pk, slot, &message)); assert!(!sig.is_valid(&pk, slot, &H256::from([43u8; 32]))); @@ -195,16 +185,17 @@ mod tests { #[test] #[ignore = "slow: XMSS key generation and signing"] fn sign_out_of_range_fails() { - let sk = generate_key([9u8; 32], 100, 32); + let sk = generate_key([9u8; 32], 100, 131); let message = H256::from([1u8; 32]); - // Slot 0 is outside the key's activation range [100, 132). + // Slot 0 is outside the key's range [100, 131]. assert!(sk.sign(0, &message).is_err()); + assert!(sk.prepare(0).is_err()); } #[test] #[ignore = "slow: XMSS key generation"] fn public_key_ssz_round_trip() { - let sk = generate_key([3u8; 32], 0, 16); + let sk = generate_key([3u8; 32], 0, 15); let pk = sk.public_key(); let bytes = pk.to_bytes(); assert_eq!(bytes.len(), PUBLIC_KEY_SIZE); diff --git a/crates/common/crypto/tests/arena.rs b/crates/common/crypto/tests/arena.rs index 1655b71d..789465d8 100644 --- a/crates/common/crypto/tests/arena.rs +++ b/crates/common/crypto/tests/arena.rs @@ -10,23 +10,21 @@ use ethlambda_crypto::{ verify_aggregated_signature, }; use ethlambda_types::primitives::H256; -use lean_multisig::{xmss_key_gen_from_seed, xmss_sign}; -use ssz::Encode as _; +use leanvm::xmss::{self, Encode as _, key_gen_from_seed}; -/// Mirrors the lib tests' helper: a small active range keeps key generation fast. +/// Mirrors the lib tests' helper: a small slot range keeps key generation fast. fn keypair_and_signature( seed: u64, - activation_epoch: u32, - signing_epoch: u32, + first_slot: u32, + signing_slot: u32, message: &H256, ) -> (ValidatorPublicKey, ValidatorSignature) { let mut seed_bytes = [0u8; 32]; seed_bytes[..8].copy_from_slice(&seed.to_le_bytes()); - let num_active_slots = 64u64; - let (pk, sk) = xmss_key_gen_from_seed(seed_bytes, activation_epoch as u64, num_active_slots) - .expect("valid activation range"); - let sig = xmss_sign(&sk, signing_epoch, &message.0).expect("sign"); + let (sk, pk) = + key_gen_from_seed(seed_bytes, first_slot, first_slot + 63).expect("valid slot range"); + let sig = xmss::sign(&mut leanvm::rand::rng(), &sk, &message.0, signing_slot).expect("sign"); ( ValidatorPublicKey::from_bytes(&pk.as_ssz_bytes()).unwrap(), diff --git a/crates/common/test-fixtures/src/fork_choice.rs b/crates/common/test-fixtures/src/fork_choice.rs index c6c9e58c..93904d95 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -138,8 +138,8 @@ pub struct AttestationStepData { /// steps (leanSpec PR #717 schema). /// /// `participants` arrives as `{ data: [bool, ...] }` and `proof` as -/// `{ data: "0x" }`; the latter is the lean-multisig single-message -/// aggregate `compress_without_pubkeys()` bytes for that AttestationData. +/// `{ data: "0x" }`; the latter is the leanVM single-message +/// aggregate `to_bytes_without_pubkeys()` bytes for that AttestationData. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProofStepData { diff --git a/crates/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs index a425ac81..d3693dfb 100644 --- a/crates/common/test-fixtures/src/verify_signatures.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -71,7 +71,7 @@ pub struct TestSignedBlock { /// Merged multi-message aggregate proof container for `SignedBlock.proof` /// (leanSpec PR #799). /// -/// The multi-signature container nests the raw lean-multisig wire one level +/// The multi-signature container nests the raw leanVM wire one level /// deep: `{ "proof": { "data": "0x..." } }`. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] @@ -135,7 +135,7 @@ impl TestSignedBlock { /// Materialize a `SignedBlock` preserving the fixture-supplied merged /// multi-message aggregate proof bytes verbatim. /// - /// The container carries the raw lean-multisig wire in the + /// The container carries the raw leanVM wire in the /// `MultiMessageAggregate` stored by `SignedBlock.proof`. pub fn try_into_signed_block_with_proofs(self) -> Result { let bytes = self diff --git a/docs/benchmarking.md b/docs/benchmarking.md index bfc9277b..e21126b6 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -79,7 +79,7 @@ otherwise, because a mis-attributed report is worse than no report. Block-building benchmark — synthetic workload (mock crypto) validators=8 warmup_slots=8 iterations=10 proofs_per_data=1 seed=42 enable_proposer_aggregation=false max_attestations_per_block=3 - ethlambda/v0.1.0/aarch64-apple-darwin/rustc-v1.97.1 leanvm=a5909d18 os=macos arch=aarch64 threads=14 + ethlambda/v0.1.0/aarch64-apple-darwin/rustc-v1.97.1 leanvm=5a4f55c1 os=macos arch=aarch64 threads=14 iter compact select_payloads stf_simulate overhead wall root 1 0.000ms 0.002ms 0.015ms 0.068ms 0.085ms 0x7282cc99 @@ -91,7 +91,7 @@ Block-building benchmark — synthetic workload (mock crypto) ``` Every measured iteration gets its own row, and the summary follows below it. -Outliers are never discarded: XMSS signing and OTS window advancement produce +Outliers are never discarded: XMSS signing and its Merkle-subtree cache misses produce legitimate heavy tails, and hiding them would misrepresent the thing being measured. A coefficient of variation above 10% is flagged so a noisy run is not mistaken for a result. From ef1052110122e0f7ead64fc7427b176017ecdc06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:47:27 -0300 Subject: [PATCH 19/20] build: enable carryless multiply on aarch64 targets leanVM main proves over a binary field tower, where multiplication is a carryless multiply. The x86 entry has enabled its instruction all along (`+pclmulqdq`); there was no aarch64 entry, because the devnet hosts are x86. That gap only became load-bearing with this leanVM: the old Poseidon over KoalaBear prover worked in a 31-bit prime field and never issued a carryless multiply at all. `aarch64-unknown-linux-gnu` defaults to `neon` alone, and LLVM gates PMULL behind the `aes` feature, so a Linux container on an Apple-silicon host built the one configuration with no hardware support for the prover's inner loop. `aarch64-apple-darwin` defaults to a CPU that already has `aes`, which is why a native `cargo test` never showed it. Measured on an M4 Max, one interval-2 aggregation of three XMSS signatures in a three-node local devnet: | build | aggregation | | --- | --- | | before | 27 s, then 56 s, then 83 s as sessions queued | | after | 214-343 ms | Before, no node ever finished a block: a single uncontended node took 60.6 s to build a zero-attestation block, against a 4 s slot. After, the same devnet imports blocks and the head advances every slot. `neoverse-n1` is the target CPU rather than `native` so the build stays reproducible across hosts; it is armv8.2, which both Apple silicon and the Neoverse N1/V1 hosts clear. --- .cargo/config.toml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index 934b5126..0374e829 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,25 @@ +# leanVM proves over a binary field tower, whose multiplication is a carryless +# multiply. Both entries below have to enable it, or the prover falls back to +# scalar bit-twiddling: `+pclmulqdq` on x86, and on aarch64 the `aes` feature, +# which is what LLVM gates PMULL behind. +# +# The aarch64 entry is not symmetry for its own sake. `aarch64-unknown-linux-gnu` +# defaults to `neon` alone, while `aarch64-apple-darwin` defaults to a CPU that +# already has `aes`, so a Linux container on an Apple-silicon host was the one +# build with no carryless multiply. Measured on an M4 Max, one aggregation proof +# went from ~30 s without this to well under a second with it, which is the +# difference between a local devnet that cannot fill a slot and one that can. + [target.x86_64-unknown-linux-gnu] rustflags = [ "-Ctarget-cpu=x86-64-v3", "-Ctarget-feature=+avx2,+sse2,+ssse3,+sse4.1,+sse4.2,+bmi1,+lzcnt,+pclmulqdq", ] + +[target.aarch64-unknown-linux-gnu] +rustflags = [ + # Baseline armv8.2 (the floor for Apple silicon and Neoverse N1/V1), plus the + # crypto extensions carrying PMULL and SHA-2. + "-Ctarget-cpu=neoverse-n1", + "-Ctarget-feature=+aes,+sha2,+neon", +] From 27a90c15315ac0cc2f8319b07204c98ba46801eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:15:58 -0300 Subject: [PATCH 20/20] feat(cli): accept a `keygen` sub-command for validator keys `ethlambda keygen` writes the key set and manifest `--hash-sig-keys-dir` reads, in the layout `hash-sig-cli generate` produces, so `generate-genesis.sh` can call the client instead of a separately pinned image. ## Why the client should own this An XMSS key file is only usable by a client built against the same scheme, and the scheme lives in leanVM, which ethlambda pins. The trap is that nothing in the file layout changes when the scheme does: leanVM's move from Poseidon over KoalaBear to BLAKE2s over binary fields kept the public key at 32 SSZ bytes and the secret key in postcard, so a key set from the wrong revision satisfies every check a genesis generator makes and fails only once a signature is verified. That reads as a consensus bug rather than a provisioning one. Generating here removes the second pin. The keys come from the same `ethlambda-crypto` types the node loads them with, through two new constructors on `ValidatorSecretKey`, so the generator and the loader cannot disagree about the format. `generate` takes the signable slots as an INCLUSIVE range, matching leanVM and making the off-by-one that gives every key one epoch too many hard to write. ## The manifest gains `leanvm_rev` Every other field is either derived from parameters that outlived the last scheme change or hardcoded. `key_scheme` is built from the lifetime, `V` and `CHAIN_LENGTH`, all three unchanged across the BLAKE2s rewrite, so it labels the two formats identically; `hash_function` does separate them but is a string, leanVM's facade exporting no name for its hash and neither of the other two parameters. The revision is resolved from `Cargo.lock` at build time and is the one field that cannot drift from what the binary links, so it is what a reader should trust. It also already existed, embedded for the benchmark report. ## Choices worth naming - `--force` gates overwriting an existing key set. A key is one-time-use material: replacing a set validators are still signing with makes each of them sign twice at one slot, under a key someone else now holds. Silence is the wrong default there. - No `--export-format`. `hash-sig-cli` took one to choose between `ssz` and a `both` that additionally wrote serde-JSON dumps its own help called legacy; nothing reads those, so there is one output form and no flag to pick it. A caller carrying `--export-format ssz` has to drop the argument. - `--num-validators` defaults to 1, leaving `--output-dir` the only required flag: one validator's pair is the shape for inspecting a key or replacing a single node's, and a genesis passes the count anyway. - Serial generation, as `hash-sig-cli` does. leanVM already fans one key out over the bottom subtrees and saturates the cores. - No `--seed`. Deriving 2N keys from one seed needs a KDF this has no reason to invent; `ValidatorSecretKey::generate_from_seed` is there for when it does. ## Verified Generated three validators at 2^18 epochs (9.8 s), then built a devnet genesis from them with `generate-genesis.sh`: keygen skipped as already present, scheme reported, and the 32-byte pubkeys landed in `config.yaml`. Tests cover the key set being loadable by `ValidatorSecretKey::from_bytes`, its public keys matching both the paired secret and the manifest, every generated key being distinct, the refusal to overwrite, and that `keygen` is not swallowed by the default sub-command. --- CLAUDE.md | 5 + Cargo.lock | 1 + bin/ethlambda/Cargo.toml | 2 + bin/ethlambda/src/command.rs | 81 +++++- bin/ethlambda/src/keygen.rs | 397 ++++++++++++++++++++++++++ bin/ethlambda/src/main.rs | 23 ++ bin/ethlambda/src/version.rs | 9 + crates/common/crypto/src/signature.rs | 124 +++++++- docs/SUMMARY.md | 1 + docs/keygen.md | 117 ++++++++ 10 files changed, 754 insertions(+), 6 deletions(-) create mode 100644 bin/ethlambda/src/keygen.rs create mode 100644 docs/keygen.md diff --git a/CLAUDE.md b/CLAUDE.md index 4a7f5587..679b80e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -275,6 +275,11 @@ actual_slot = finalized_slot + 1 + relative_index proving or proof decoding. `--prover-arena` opts into leanVM's bump arena, which recycles the prover's large buffers across proofs instead of re-faulting them, so its pages stay resident for the node's lifetime +- `ethlambda keygen` generates genesis validator keys through the same + `ValidatorSecretKey` the node loads them with, so a key set cannot be built + against a different leanVM than the client reading it. Keys are only usable by + a client on the matching revision, and no file size changes when the scheme + does, so the manifest records `leanvm_rev`. See [`docs/keygen.md`](docs/keygen.md) **Aggregation shape (one leanVM `AggregateSignature`, grouped by epoch):** - Type-1 and Type-2 are the same object: one `XmssGroup` per slot, carrying the diff --git a/Cargo.lock b/Cargo.lock index 4c77fc05..61184c1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1954,6 +1954,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "tempfile", "thiserror 2.0.18", "tikv-jemallocator", "tokio", diff --git a/bin/ethlambda/Cargo.toml b/bin/ethlambda/Cargo.toml index 591490ca..a22f0447 100644 --- a/bin/ethlambda/Cargo.toml +++ b/bin/ethlambda/Cargo.toml @@ -56,6 +56,8 @@ libc.workspace = true # tests would otherwise wait out the real retry backoff. Dev-only, so the # feature never reaches the shipped binary. tokio = { workspace = true, features = ["test-util"] } +# Keygen writes a directory of key files, so its tests need a throwaway one. +tempfile = "3" [build-dependencies] vergen-git2.workspace = true diff --git a/bin/ethlambda/src/command.rs b/bin/ethlambda/src/command.rs index 4483a974..85a61e7a 100644 --- a/bin/ethlambda/src/command.rs +++ b/bin/ethlambda/src/command.rs @@ -14,14 +14,25 @@ use clap::Parser; use crate::benchmark::BenchmarkOptions; use crate::cli::NodeOptions; +use crate::keygen::KeygenOptions; use crate::version; /// Tokens that already say what to run, so no default is inserted ahead of /// them. `help` is clap's own generated sub-command (`ethlambda help node`). -const EXPLICIT: &[&str] = &[NODE, BENCHMARK, "help", "-h", "--help", "-V", "--version"]; +const EXPLICIT: &[&str] = &[ + NODE, + BENCHMARK, + KEYGEN, + "help", + "-h", + "--help", + "-V", + "--version", +]; const NODE: &str = "node"; const BENCHMARK: &str = "benchmark"; +const KEYGEN: &str = "keygen"; #[derive(Debug, clap::Parser)] #[command( @@ -61,6 +72,8 @@ pub(crate) enum Command { Node(NodeOptions), /// Benchmark block building offline against a controlled workload. Benchmark(BenchmarkOptions), + /// Generate validator XMSS keys for a genesis. + Keygen(KeygenOptions), } /// Parse the process arguments, exiting the way clap does on a parse error, @@ -259,6 +272,72 @@ mod tests { assert_eq!(printed[0], printed[2]); } + /// Every sub-command token has to be in `EXPLICIT`, or the default is + /// inserted ahead of it and `ethlambda keygen ...` parses as + /// `ethlambda node keygen ...`, which fails on a stray positional. + #[test] + fn sub_command_tokens_do_not_get_the_default_inserted() { + for token in [NODE, BENCHMARK, KEYGEN] { + let args = [OsString::from("ethlambda"), OsString::from(token)]; + assert_eq!( + default_subcommand(&args), + None, + "`{token}` must be recognised as a sub-command" + ); + } + } + + #[test] + fn keygen_parses_as_its_own_sub_command() { + let args = [ + "ethlambda", + KEYGEN, + "--num-validators", + "4", + "--output-dir", + "keys", + ]; + match try_parse_from(args.iter().map(OsString::from)).expect("keygen parses") { + Command::Keygen(_) => {} + other => panic!("expected a keygen invocation, got {other:?}"), + } + } + + /// The output directory is the only thing keygen cannot guess, so a bare + /// `keygen --output-dir` has to parse. The default it lands on is asserted + /// through `Debug`, as the node options are above, rather than by widening + /// the parser's fields for a test. + #[test] + fn keygen_defaults_to_a_single_validator() { + let args = ["ethlambda", KEYGEN, "--output-dir", "keys"]; + match try_parse_from(args.iter().map(OsString::from)).expect("keygen parses") { + Command::Keygen(options) => { + assert!( + format!("{options:?}").contains("num_validators: 1"), + "{options:?}" + ); + } + other => panic!("expected a keygen invocation, got {other:?}"), + } + } + + /// `--num-validators 0` would write a manifest with no validators in it, + /// which a genesis generator then sums to a zero validator count. + #[test] + fn keygen_rejects_an_empty_validator_set() { + let args = [ + "ethlambda", + KEYGEN, + "--num-validators", + "0", + "--output-dir", + "keys", + ]; + let err = try_parse_from(args.iter().map(OsString::from)) + .expect_err("zero validators must be rejected"); + assert_eq!(err.kind(), ErrorKind::ValueValidation); + } + #[test] fn help_lists_the_sub_commands() { // Listed by clap itself, because they are real sub-commands. diff --git a/bin/ethlambda/src/keygen.rs b/bin/ethlambda/src/keygen.rs new file mode 100644 index 00000000..acf4062f --- /dev/null +++ b/bin/ethlambda/src/keygen.rs @@ -0,0 +1,397 @@ +//! Validator key generation (`ethlambda keygen`). +//! +//! Writes the key set and manifest that `--hash-sig-keys-dir` reads, in the +//! layout `hash-sig-cli generate` produces, so `generate-genesis.sh` can call +//! this instead. Only the SSZ-and-postcard form is written: `hash-sig-cli` also +//! dumped each key as serde JSON, which its own help called legacy and nothing +//! reads. +//! +//! # Why the node generates its own keys +//! +//! An XMSS key file is only usable by a client built against the same scheme, +//! and the scheme lives in leanVM, which ethlambda pins. Nothing in the file +//! layout changes when the scheme does: leanVM's move from Poseidon over +//! KoalaBear to BLAKE2s over binary fields kept the public key at +//! [`PUBLIC_KEY_SIZE`] bytes and the secret key in postcard, so a key set from +//! the wrong revision passes every format check a genesis generator makes and +//! fails only once a signature is verified. +//! +//! Generating here removes the second pin that has to be kept in step: these +//! keys come from the same `ethlambda-crypto` types the node loads them with, +//! so the two cannot disagree. The manifest also records the leanVM revision +//! the binary was built against, which is the one field that cannot drift. + +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use ethlambda_crypto::signature::{ValidatorSecretKey, scheme}; +use ethlambda_types::state::PUBLIC_KEY_SIZE; +use eyre::{Context as _, bail}; +use tracing::info; + +#[derive(Debug, clap::Args)] +pub(crate) struct KeygenOptions { + /// Number of validators to generate a key pair for. + /// + /// Each validator gets two independent keys, an attester and a proposer, so + /// that it can sign an attestation and a block in the same slot. The + /// default generates one validator's pair, which is the shape for + /// inspecting a key or replacing a single node's. + #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u32).range(1..))] + num_validators: u32, + + /// Log2 of the slots each key can sign at, counted from slot 0. + /// + /// The default matches what the devnets generate. A key cannot sign past + /// its range, so this is the network's lifetime: at the default cadence + /// 2^18 slots is about 12 days. + #[arg(long, default_value_t = 18, value_parser = clap::value_parser!(u32).range(1..=32))] + log_num_active_epochs: u32, + + /// Directory to write the keys and manifest into. Created if absent. + #[arg(long)] + output_dir: PathBuf, + + /// Write `validator-keys-manifest.yaml` alongside the keys. + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + create_manifest: bool, + + /// Name each validator by the first and last three bytes of its proposer + /// public key rather than by index, for key sets split across hosts. + #[arg(long)] + distributed: bool, + + /// Overwrite key files already in `--output-dir`. + /// + /// Off by default: a key is one-time-use material, and silently replacing a + /// set that validators are already signing with would make every one of + /// them sign twice at the same slot under a key someone else now holds. + #[arg(long)] + force: bool, +} + +/// One validator's generated key pair, as the manifest records it. +struct Validator { + /// `validator_`, or the pubkey-derived name under `--distributed`. + name: String, + proposer_pubkey: Vec, + attester_pubkey: Vec, +} + +pub(crate) fn run(options: KeygenOptions) -> eyre::Result<()> { + let KeygenOptions { + num_validators, + log_num_active_epochs, + output_dir, + create_manifest, + distributed, + force, + } = options; + + // Inclusive, so `2^n` slots is `0..=2^n - 1`. The exclusive reading is an + // easy off-by-one to make here and gives every key one epoch too many. + let last_slot = (1u64 << log_num_active_epochs) - 1; + let last_slot = u32::try_from(last_slot) + .wrap_err_with(|| format!("2^{log_num_active_epochs} slots exceeds the XMSS lifetime"))?; + let slots = 0..=last_slot; + + fs::create_dir_all(&output_dir) + .wrap_err_with(|| format!("failed to create {}", output_dir.display()))?; + + info!( + num_validators, + log_num_active_epochs, + signable_slots = last_slot as u64 + 1, + scheme = scheme::NAME, + output_dir = %output_dir.display(), + "Generating validator keys" + ); + + let mut validators = Vec::with_capacity(num_validators as usize); + for index in 0..num_validators { + // Two keys per validator, each with its own secret material: a shared + // key would spend one slot's leaf on whichever duty signed first. + let proposer = ValidatorSecretKey::generate(slots.clone()) + .wrap_err_with(|| format!("proposer key generation failed for validator {index}"))?; + let attester = ValidatorSecretKey::generate(slots.clone()) + .wrap_err_with(|| format!("attester key generation failed for validator {index}"))?; + + let proposer_pubkey = proposer.public_key().to_bytes(); + let attester_pubkey = attester.public_key().to_bytes(); + let name = validator_name(index, distributed, &proposer_pubkey); + + write_key_pair(&output_dir, &name, "proposer", &proposer, force)?; + write_key_pair(&output_dir, &name, "attester", &attester, force)?; + + info!(index, name, "Generated validator key pair"); + validators.push(Validator { + name, + proposer_pubkey, + attester_pubkey, + }); + } + + if create_manifest { + let path = write_manifest(&output_dir, log_num_active_epochs, distributed, &validators)?; + info!(manifest = %path.display(), "Wrote validator key manifest"); + } + + Ok(()) +} + +/// `validator_`, or `validator--` of the proposer public key. +/// +/// The distributed form lets key directories from separate hosts be merged +/// without their indices colliding, at the cost of an ordering the genesis +/// generator has to take from the manifest rather than from the file names. +fn validator_name(index: u32, distributed: bool, proposer_pubkey: &[u8]) -> String { + if !distributed { + return format!("validator_{index}"); + } + let (first, last) = proposer_pubkey.split_at(3); + format!( + "validator-{}-{}", + hex::encode(first), + hex::encode(&last[last.len() - 3..]) + ) +} + +/// Write one role's `_pk.ssz` and `_sk.ssz`. +/// +/// The secret key is postcard rather than SSZ, which has no encoding for it; +/// the extension is kept so the genesis tooling needs no special case. +fn write_key_pair( + output_dir: &Path, + name: &str, + role: &str, + key: &ValidatorSecretKey, + force: bool, +) -> eyre::Result<()> { + let public = key.public_key().to_bytes(); + debug_assert_eq!(public.len(), PUBLIC_KEY_SIZE); + let secret = key + .to_bytes() + .map_err(|err| eyre::eyre!("failed to encode the {role} secret key: {err}"))?; + + write_new( + &output_dir.join(format!("{name}_{role}_key_pk.ssz")), + &public, + force, + )?; + write_new( + &output_dir.join(format!("{name}_{role}_key_sk.ssz")), + &secret, + force, + ) +} + +/// Write `path`, refusing to replace an existing file unless `force`. +fn write_new(path: &Path, bytes: &[u8], force: bool) -> eyre::Result<()> { + if !force && path.exists() { + bail!( + "{} already exists; pass --force to replace the key set, \ + but only once no validator is still signing with it", + path.display() + ); + } + fs::write(path, bytes).wrap_err_with(|| format!("failed to write {}", path.display())) +} + +/// Write the manifest the genesis generator reads the public keys back from. +/// +/// Hand-rolled rather than serialized from a struct, to hold the field order +/// and the blank lines `hash-sig-cli` produces: a diff between two key sets' +/// manifests is worth keeping readable. +fn write_manifest( + output_dir: &Path, + log_num_active_epochs: u32, + distributed: bool, + validators: &[Validator], +) -> eyre::Result { + let path = output_dir.join("validator-keys-manifest.yaml"); + let mut out = Vec::new(); + + writeln!(out, "# Hash-Signature Validator Keys Manifest")?; + writeln!(out, "# Generated by ethlambda keygen\n")?; + writeln!(out, "key_scheme: {}", scheme::NAME)?; + writeln!(out, "hash_function: {}", scheme::HASH_FUNCTION)?; + writeln!(out, "encoding: {}", scheme::ENCODING)?; + writeln!(out, "pubkey_bytes: {}", scheme::PUBLIC_KEY_BYTES)?; + writeln!(out, "lifetime: {}", scheme::LIFETIME)?; + // The scheme fields above are all derived from parameters that outlived the + // last scheme change, so this is what actually identifies the key format. + writeln!(out, "leanvm_rev: {}", crate::version::LEANVM_REV)?; + writeln!(out, "log_num_active_epochs: {log_num_active_epochs}")?; + writeln!(out, "num_active_epochs: {}", 1u64 << log_num_active_epochs)?; + writeln!(out, "num_validators: {}\n", validators.len())?; + writeln!(out, "validators:")?; + + for (index, validator) in validators.iter().enumerate() { + // The distributed layout names validators by key rather than position, + // so an index would be a second, conflicting identity. + if !distributed { + writeln!(out, " - index: {index}")?; + } + let lead = if distributed { " - " } else { " " }; + writeln!( + out, + "{lead}proposer_key_pubkey_hex: 0x{}", + hex::encode(&validator.proposer_pubkey) + )?; + writeln!( + out, + " proposer_key_privkey_file: {}_proposer_key_sk.ssz", + validator.name + )?; + writeln!( + out, + " attester_key_pubkey_hex: 0x{}", + hex::encode(&validator.attester_pubkey) + )?; + writeln!( + out, + " attester_key_privkey_file: {}_attester_key_sk.ssz", + validator.name + )?; + writeln!(out)?; + } + + fs::write(&path, out).wrap_err_with(|| format!("failed to write {}", path.display()))?; + Ok(path) +} + +#[cfg(test)] +mod tests { + use ethlambda_crypto::signature::ValidatorPublicKey; + + use super::*; + + /// Read a public key back out of a generated `_pk.ssz`, which is what the + /// node's own validator registry does with it. + fn read_public_key(path: &Path) -> eyre::Result { + let bytes = + fs::read(path).wrap_err_with(|| format!("failed to read {}", path.display()))?; + ValidatorPublicKey::from_bytes(&bytes) + .map_err(|err| eyre::eyre!("{} is not a public key: {err}", path.display())) + } + + fn options(dir: &Path, num_validators: u32) -> KeygenOptions { + KeygenOptions { + num_validators, + // The smallest range that still exercises the subtree split, so the + // tests stay fast: key generation cost grows with the range. + log_num_active_epochs: 4, + output_dir: dir.to_path_buf(), + create_manifest: true, + distributed: false, + force: false, + } + } + + #[test] + fn indexed_names_are_positional() { + assert_eq!(validator_name(0, false, &[0xab; 32]), "validator_0"); + assert_eq!(validator_name(17, false, &[0xab; 32]), "validator_17"); + } + + /// The distributed name has to come from the key's own bytes, so two hosts + /// generating independently cannot collide. + #[test] + fn distributed_names_come_from_the_proposer_key() { + let mut pubkey = [0u8; PUBLIC_KEY_SIZE]; + pubkey[..3].copy_from_slice(&[0x01, 0x02, 0x03]); + pubkey[PUBLIC_KEY_SIZE - 3..].copy_from_slice(&[0x0a, 0x0b, 0x0c]); + + let name = validator_name(0, true, &pubkey); + assert_eq!(name, "validator-010203-0a0b0c"); + // The index is not in it, so the same key names the same validator + // wherever it was generated. + assert_eq!(name, validator_name(9, true, &pubkey)); + } + + /// The whole point of generating here: what comes out has to be what the + /// node's key loader reads back, both keys and manifest. + #[test] + #[ignore = "slow: XMSS key generation"] + fn generated_set_is_loadable_and_matches_its_manifest() { + let dir = tempfile::tempdir().expect("tempdir"); + run(options(dir.path(), 2)).expect("keygen succeeds"); + + let manifest = fs::read_to_string(dir.path().join("validator-keys-manifest.yaml")) + .expect("manifest written"); + assert!(manifest.contains("num_validators: 2"), "{manifest}"); + assert!(manifest.contains("num_active_epochs: 16"), "{manifest}"); + assert!( + manifest.contains(&format!("pubkey_bytes: {PUBLIC_KEY_SIZE}")), + "{manifest}" + ); + + for index in 0..2 { + for role in ["proposer", "attester"] { + let name = format!("validator_{index}"); + let public = read_public_key(&dir.path().join(format!("{name}_{role}_key_pk.ssz"))) + .expect("public key parses"); + let secret = fs::read(dir.path().join(format!("{name}_{role}_key_sk.ssz"))) + .expect("secret key written"); + let secret = ValidatorSecretKey::from_bytes(&secret).expect("secret key parses"); + + // The pair has to belong together, and the manifest has to + // name the same public key the file holds. + assert_eq!(secret.public_key().to_bytes(), public.to_bytes()); + assert_eq!(secret.signable_slots(), 0..=15); + let hex = format!("0x{}", hex::encode(public.to_bytes())); + assert!( + manifest.contains(&hex), + "{role} {index} missing from manifest" + ); + } + } + } + + /// Two validators must not share key material, however close together they + /// were generated. + #[test] + #[ignore = "slow: XMSS key generation"] + fn every_generated_key_is_distinct() { + let dir = tempfile::tempdir().expect("tempdir"); + run(options(dir.path(), 2)).expect("keygen succeeds"); + + let mut seen = std::collections::HashSet::new(); + for index in 0..2 { + for role in ["proposer", "attester"] { + let path = dir + .path() + .join(format!("validator_{index}_{role}_key_pk.ssz")); + let public = read_public_key(&path).expect("public key parses"); + assert!( + seen.insert(public.to_bytes()), + "validator_{index} {role} repeats another key" + ); + } + } + } + + /// Replacing a live key set would make every validator in it sign twice at + /// the same slot, so it takes an explicit `--force`. + #[test] + #[ignore = "slow: XMSS key generation"] + fn an_existing_key_set_is_not_overwritten_by_default() { + let dir = tempfile::tempdir().expect("tempdir"); + run(options(dir.path(), 1)).expect("first keygen succeeds"); + let before = fs::read(dir.path().join("validator_0_proposer_key_sk.ssz")).expect("written"); + + let err = run(options(dir.path(), 1)).expect_err("a second run must refuse"); + assert!(err.to_string().contains("--force"), "{err}"); + + let after = fs::read(dir.path().join("validator_0_proposer_key_sk.ssz")).expect("intact"); + assert_eq!(before, after, "the refused run must not have written"); + + let mut forced = options(dir.path(), 1); + forced.force = true; + run(forced).expect("--force replaces the set"); + let replaced = + fs::read(dir.path().join("validator_0_proposer_key_sk.ssz")).expect("written"); + assert_ne!(before, replaced, "--force must generate fresh material"); + } +} diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index ca5e0e4f..abc5064e 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -3,6 +3,7 @@ mod checkpoint_sync; mod cli; mod command; mod fd_limit; +mod keygen; mod version; // Jemalloc causes programs to deadlock during process startup under Shadow. @@ -82,6 +83,12 @@ fn main() -> eyre::Result<()> { init_benchmark_logging()?; benchmark::run(options) } + // Key generation is synchronous, CPU-bound work whose product is files, + // so it runs on this thread too and leaves stdout alone. + Command::Keygen(options) => { + init_keygen_logging()?; + keygen::run(options) + } } } @@ -110,6 +117,22 @@ fn init_benchmark_logging() -> eyre::Result<()> { .wrap_err("failed to set global tracing subscriber") } +/// Keygen logging: INFO and above, on stderr. Key generation takes minutes for a +/// large validator set, so progress has to be visible; stderr keeps stdout free +/// for a machine-readable summary to claim later. +fn init_keygen_logging() -> eyre::Result<()> { + let filter = EnvFilter::builder() + .with_default_directive(tracing::Level::INFO.into()) + .from_env_lossy(); + let subscriber = Registry::default().with( + tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_filter(filter), + ); + tracing::subscriber::set_global_default(subscriber) + .wrap_err("failed to set global tracing subscriber") +} + // Shadow single-steps execution in a discrete-event simulation, so the default // multi-threaded runtime's worker threads add only scheduling noise, never // parallelism. Use a single-threaded runtime under Shadow. This is an diff --git a/bin/ethlambda/src/version.rs b/bin/ethlambda/src/version.rs index c2977f22..c86a1766 100644 --- a/bin/ethlambda/src/version.rs +++ b/bin/ethlambda/src/version.rs @@ -1,3 +1,12 @@ +/// The leanVM revision the binary was built against, resolved from `Cargo.lock` +/// by `build.rs`. +/// +/// leanVM owns the whole signature stack, so this identifies the XMSS scheme +/// this build signs, verifies and generates keys for. Worth recording next to +/// anything that outlives the process, since the scheme's own parameters have +/// survived a change of hash function unchanged and so cannot stand in for it. +pub const LEANVM_REV: &str = env!("ETHLAMBDA_LEANVM_REV"); + /// Client version string with git info. /// Format: ethlambda/v0.1.0-main-892ad575.../x86_64-unknown-linux-gnu/rustc-v1.85.0 pub const CLIENT_VERSION: &str = concat!( diff --git a/crates/common/crypto/src/signature.rs b/crates/common/crypto/src/signature.rs index ba86e48b..9dbb65bc 100644 --- a/crates/common/crypto/src/signature.rs +++ b/crates/common/crypto/src/signature.rs @@ -5,8 +5,8 @@ use std::ops::RangeInclusive; use ethlambda_types::{attestation::SIGNATURE_SIZE, primitives::H256, state::PUBLIC_KEY_SIZE}; use leanvm::xmss::{ - self, Decode, Encode, PUB_KEY_SSZ_LEN, SIGNATURE_SSZ_LEN, XmssPublicKey, XmssSecretKey, - XmssSignError, XmssSignature, + self, Decode, Encode, LOG_LIFETIME, PUB_KEY_SSZ_LEN, SIGNATURE_SSZ_LEN, XmssKeyGenError, + XmssPublicKey, XmssSecretKey, XmssSignError, XmssSignature, }; // `ethlambda-types` hardcodes the XMSS wire sizes so it can stay free of the @@ -31,6 +31,48 @@ const _: [(); PUBLIC_KEY_SIZE] = [(); PUB_KEY_SSZ_LEN]; #[error("signature parse error: {0}")] pub struct SignatureParseError(pub String); +/// What the pinned leanVM's XMSS is, for a genesis key manifest to record. +/// +/// A key set is only usable by a client built against the same scheme, and no +/// file size changes when the scheme does (leanVM's move from Poseidon over +/// KoalaBear to BLAKE2s over binary fields kept both wire sizes), so a manifest +/// has to say which scheme it holds rather than leave it to be inferred. +/// +/// Only two of these fields are load-bearing. [`PUBLIC_KEY_BYTES`] and +/// [`LIFETIME`] come from leanVM and so cannot drift; the two labels are +/// hardcoded, because leanVM's facade exports neither a name for the hash nor +/// the `V`/`CHAIN_LENGTH` the scheme label is conventionally built from. That is +/// exactly why a manifest should also carry the leanVM revision, which is the +/// one field that identifies the format on its own. +pub mod scheme { + use super::{LOG_LIFETIME, PUB_KEY_SSZ_LEN}; + + /// The scheme label, in the form the genesis tooling has always read. + /// + /// `Dim`/`Base` are leanVM's `V` and `CHAIN_LENGTH`, neither re-exported; + /// keep this in step with the pinned revision. Note that all three + /// parameters survived the last change of hash function unchanged, so this + /// label does NOT distinguish the two key formats by itself. + pub const NAME: &str = "XmssTargetSumLifetime32Dim42Base8"; + + /// The hash the scheme is built on. Hardcoded, as [`NAME`] is. + pub const HASH_FUNCTION: &str = "BLAKE2s"; + + /// The WOTS encoding: the signer grinds randomness until the digest's + /// chunks sum to a fixed target, so there are no checksum chains. + pub const ENCODING: &str = "TargetSum"; + + /// Epochs a key could cover at most, whatever range it was generated for. + pub const LIFETIME: u64 = 1 << LOG_LIFETIME; + + /// Bytes in an SSZ-encoded public key. + pub const PUBLIC_KEY_BYTES: usize = PUB_KEY_SSZ_LEN; + + // [`NAME`] claims a 2^32 lifetime, which leanVM does export, so at least + // that much of the hardcoded label is checked rather than trusted. + const _: [(); 32] = [(); LOG_LIFETIME]; +} + #[derive(Clone)] pub struct ValidatorSignature { inner: XmssSignature, @@ -93,6 +135,30 @@ pub struct ValidatorSecretKey { } impl ValidatorSecretKey { + /// Generate a fresh key able to sign at each slot in `slots`, once. + /// + /// The range is inclusive and fixed for the key's life: a slot outside it + /// can never be signed. Nothing can regenerate the key, the seed coming + /// from the OS. + /// + /// Cost grows with the range, but sublinearly: leanVM stores + /// `O(sqrt(range))` of the tree and fans key generation out over the bottom + /// subtrees. + pub fn generate(slots: RangeInclusive) -> Result { + let (sk, _pk) = xmss::key_gen(&mut leanvm::rand::rng(), *slots.start(), *slots.end())?; + Ok(Self { inner: sk }) + } + + /// Deterministic [`Self::generate`]: one `(seed, slots)` always rebuilds the + /// same key, the seed being its entire secret material. + pub fn generate_from_seed( + seed: [u8; 32], + slots: RangeInclusive, + ) -> Result { + let (sk, _pk) = xmss::key_gen_from_seed(seed, *slots.start(), *slots.end())?; + Ok(Self { inner: sk }) + } + /// Parse from the postcard-encoded key file produced by the genesis generator. /// /// leanVM's `XmssSecretKey` carries serde rather than an SSZ codec: the @@ -157,9 +223,57 @@ mod tests { /// Generate a validator key pair over a small slot range (fast key generation). fn generate_key(seed: [u8; 32], first_slot: u32, last_slot: u32) -> ValidatorSecretKey { - let (sk, _pk) = - xmss::key_gen_from_seed(seed, first_slot, last_slot).expect("valid slot range"); - ValidatorSecretKey { inner: sk } + ValidatorSecretKey::generate_from_seed(seed, first_slot..=last_slot) + .expect("valid slot range") + } + + /// The scheme label is what a key manifest is read back against, so a + /// leanVM bump that moves a parameter has to move the label with it. + #[test] + fn scheme_reports_the_pinned_parameters() { + assert_eq!(scheme::NAME, "XmssTargetSumLifetime32Dim42Base8"); + assert_eq!(scheme::PUBLIC_KEY_BYTES, PUBLIC_KEY_SIZE); + assert_eq!(scheme::LIFETIME, 1 << 32); + } + + /// A generated key has to survive the postcard round trip the node's key + /// loader performs, and keep signing for the same public key. + #[test] + #[ignore = "slow: XMSS key generation and signing"] + fn generated_key_round_trips_through_its_file_form() { + let sk = ValidatorSecretKey::generate(0..=63).expect("valid slot range"); + let pk = sk.public_key(); + + let reloaded = ValidatorSecretKey::from_bytes(&sk.to_bytes().expect("serializes")) + .expect("its own file form parses"); + assert_eq!(reloaded.public_key().to_bytes(), pk.to_bytes()); + assert_eq!(reloaded.signable_slots(), sk.signable_slots()); + + let message = H256::from([5u8; 32]); + let signature = reloaded.sign(7, &message).expect("sign"); + assert!(signature.is_valid(&pk, 7, &message)); + } + + /// The seeded path is what makes a key set reproducible. + #[test] + #[ignore = "slow: XMSS key generation"] + fn seeded_generation_is_deterministic() { + let first = ValidatorSecretKey::generate_from_seed([11u8; 32], 0..=15).expect("range"); + let again = ValidatorSecretKey::generate_from_seed([11u8; 32], 0..=15).expect("range"); + let other = ValidatorSecretKey::generate_from_seed([12u8; 32], 0..=15).expect("range"); + + assert_eq!(first.public_key().to_bytes(), again.public_key().to_bytes()); + assert_ne!(first.public_key().to_bytes(), other.public_key().to_bytes()); + } + + /// An inverted range has no epochs to sign at, so it is rejected rather + /// than yielding a key that can do nothing. + #[test] + fn an_inverted_slot_range_is_rejected() { + // Built from variables: as a literal, `10..=9` is a clippy error rather + // than the input under test. + let (first, last) = (10u32, 9u32); + assert!(ValidatorSecretKey::generate_from_seed([0u8; 32], first..=last).is_err()); } #[test] diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 6b6f9c50..43d4ccde 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -20,6 +20,7 @@ - [Fork Choice Visualization](./fork_choice_visualization.md) - [Data Storage](./data_storage.md) - [Peer discovery](./discovery.md) +- [Validator Key Generation](./keygen.md) # Development diff --git a/docs/keygen.md b/docs/keygen.md new file mode 100644 index 00000000..9e74263c --- /dev/null +++ b/docs/keygen.md @@ -0,0 +1,117 @@ +# Validator Key Generation + +`ethlambda keygen` writes the validator XMSS key set and manifest that +`--hash-sig-keys-dir` reads, in the layout `hash-sig-cli generate` produces. + +```bash +# One validator's attester and proposer pair +ethlambda keygen --output-dir keys + +# A genesis for a three-node devnet +ethlambda keygen --num-validators 3 --output-dir local-devnet/genesis/hash-sig-keys +``` + +`--output-dir` is the only required flag. + +## Why the client generates its own keys + +An XMSS key file is only usable by a client built against the same signature +scheme, and the scheme lives in leanVM, which ethlambda pins to one revision. + +The trap is that nothing in the file layout changes when the scheme does. +leanVM's move from Poseidon over KoalaBear to BLAKE2s over binary fields kept +the public key at 32 SSZ bytes and the secret key in postcard, so a key set from +the wrong revision satisfies every check a genesis generator makes: the file +names match, the sizes match, and the manifest parses. It fails only later, when +a signature is verified, and then it looks like a consensus bug rather than a +provisioning one. + +Generating here removes the second pin. These keys come from the same +`ethlambda-crypto` types the node loads them with, so the generator and the +loader cannot disagree about the format. + +## Flags + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--num-validators ` | `1` | Validators to generate a key pair for | +| `--log-num-active-epochs ` | `18` | Log2 of the slots each key can sign at, from slot 0 | +| `--output-dir ` | required | Where to write; created if absent | +| `--create-manifest ` | `true` | Write `validator-keys-manifest.yaml` | +| `--distributed` | off | Name validators by public key rather than by index | +| `--force` | off | Replace key files already in `--output-dir` | + +Each validator gets **two** independent keys, an attester and a proposer, so it +can sign an attestation and a block in the same slot without spending one slot's +one-time leaf twice. + +`--log-num-active-epochs` is the network's lifetime, not a tuning knob: a key +cannot sign past its range, and the range is fixed at generation. At the default +cadence 2^18 slots is about 12 days, after which every validator holding such a +key stops signing. + +`--force` is off by default because a key is one-time-use material. Replacing a +set that validators are still signing with makes each of them sign twice at the +same slot, under a key someone else now holds. + +## Output + +``` +hash-sig-keys/ +├── validator_0_attester_key_pk.ssz 32 bytes, SSZ +├── validator_0_attester_key_sk.ssz postcard +├── validator_0_proposer_key_pk.ssz +├── validator_0_proposer_key_sk.ssz +├── validator_1_... +└── validator-keys-manifest.yaml +``` + +The `.ssz` extension on a secret key is a misnomer kept for the tooling's sake: +SSZ has no encoding for one, so it is postcard. + +`hash-sig-cli` had an `--export-format both` that additionally dumped each key as +serde JSON. Its own help called that legacy and nothing reads it, so this writes +only the form above and takes no format flag. A caller carrying +`--export-format ssz` from the old invocation has to drop it. + +## The manifest + +```yaml +key_scheme: XmssTargetSumLifetime32Dim42Base8 +hash_function: BLAKE2s +encoding: TargetSum +pubkey_bytes: 32 +lifetime: 4294967296 +leanvm_rev: 5a4f55c1138759f43f78483a7b70fde973e4a1ee +log_num_active_epochs: 18 +num_active_epochs: 262144 +num_validators: 3 + +validators: + - index: 0 + proposer_key_pubkey_hex: 0x... + proposer_key_privkey_file: validator_0_proposer_key_sk.ssz + attester_key_pubkey_hex: 0x... + attester_key_privkey_file: validator_0_attester_key_sk.ssz +``` + +`leanvm_rev` is ethlambda's addition, and it is the field to trust. Read against +the scheme parameters alone, two key sets from either side of the BLAKE2s rewrite +are indistinguishable: `key_scheme` is built from the lifetime, `V` and +`CHAIN_LENGTH`, and all three came through that change unaltered. `hash_function` +does separate them but is a hardcoded label, since leanVM's facade exports no +name for its hash. The revision is resolved from `Cargo.lock` at build time and +cannot drift from what the binary actually links. + +`generate-genesis.sh` reads `key_scheme` and cross-checks `pubkey_bytes` against +the pubkeys the manifest holds; the rest is informational. + +## Cost + +Key generation is `O(sqrt(range))` in memory and fans out over the bottom Merkle +subtrees, so a large range is cheaper than it looks: about 3 seconds per +validator at 2^18 epochs on an M4 Max, generated serially. A 1024-validator +genesis is therefore tens of minutes, and worth doing once and keeping. + +Building on aarch64 without the crypto extensions makes this, and every other +leanVM operation, far slower. See the aarch64 entry in `.cargo/config.toml`.