Conversation
leanVM replaced Poseidon over KoalaBear with BLAKE2s over a binary field and folded aggregation into one aggregate() call. Both glue crates now depend on the root `leanvm` crate at a single rev, so they cannot drift apart. hashsig-glue: key_gen_from_seed takes an inclusive epoch range rather than a start and count, and returns (secret, public); sign and verify take their arguments in a new order. Signing stays deterministic. A compile-time check pins the SSZ sizes the Go types hard-code. multisig-glue: rebuilt on aggregate(). Proof bytes no longer carry the messages, slots or keys they prove, so every decode is given its claims, and the signer set is canonicalised in one place shared by prover and verifier. Split re-proves the Type-2 as a child, declaring only the kept group. ffi.go: merge and split now take the message and slot of each group; proposal and recovery bind them the way block verification does. The Poseidon permutation export and its spec test are removed: leanVM no longer contains Poseidon. BREAKING CHANGE: keys, signatures and proofs from the Poseidon line do not verify. Validator keys must be regenerated with a keygen on the same rev.
A leanVM proof carries at most one message per epoch, and the epoch is the slot. A block whose attestations include two different AttestationData for the same slot therefore cannot be merged into a Type-2 proof, and the proposer's signature over the block root already holds the block's own slot. The planner now records which slots the block has taken, seeded with the block's slot, and skips any later payload at a taken slot as an expected skip. The current spec allows two data per slot, so this departs from it until the spec settles how the new proof system handles that case. The full-block test used attestation slots that included the block's own slot only to make its data distinct; they now start after it.
leanVM's prover does binary-field arithmetic with carryless multiplication. The x86_64 Haswell baseline already provides PCLMULQDQ, but on aarch64 PMULL is gated behind the `aes` target feature, which Linux aarch64 targets leave off, so the prover fell back to scalar code. make ffi and the Dockerfile now pass +aes there. The Dockerfile copied leanVM's rec_aggregation and lean_compiler sources into the runtime image because the old line could read .py files at runtime. The guest is now compiled from include_str!, the compiler rejects every import but the snark_lib stub, and nothing on the production path reads a file, so the staging is removed.
jemalloc returns freed pages to the kernel only when the arena that freed them is used again, unless its background threads are enabled, and they are off by default. A proof spreads gigabytes of scratch across the prover's worker threads and then leaves those arenas idle, so hundreds of megabytes stayed resident between proofs on every node. Measured on the 16-node devnet running this branch, one node restarted with the setting and the other fifteen without, over 10 hours: 268 MB average against 729 MB, a 185 MB floor against 546-662 MB, with block-proof time 2.632 s against 2.629 s and no errors or restarts.
The proving entry points returned -1 for every failure, so a node could only log "signature aggregation failed". leanVM's aggregate() says which of its conditions failed: an input proof that does not verify, two messages at one slot, a malformed raw signature, too many inputs, and so on. A block proof that failed once in 14,396 proposals on the devnet left nothing to diagnose. The glue now returns a distinct status for each of those, for an input proof that does not decode against its claims, a split target that is not a group of the proof, and a caught panic. ffi.go wraps ErrAggregationFailed with the matching reason. The mapping matches every AggregationError variant without a catch-all, so a variant added upstream fails the build instead of reading as a generic failure.
dimka90
force-pushed
the
feat/leanvm-blake2s
branch
from
September 19, 2026 20:14
2685557 to
0da0e9e
Compare
Building a block proof proved the proposer's single signature on its own and then merged that proof with the attestation proofs, so the proposer cost a whole proof and then one more merge input. leanVM's unified aggregate() takes proofs and raw signatures together across messages, so the merge now takes the proposer's signature raw. xmss_merge_type_1_to_type_2 and MergeType1Proofs accept raw signatures beside the input proofs; either list may be empty, so a block without attestations is proved from the proposer's signature alone. The proof carries the same claims as before, so verification is unchanged. Measured locally, the two paths interleaved over the same inputs, 3 runs each: attestation groups wrap + merge raw in merge saved 0 2.114 s 0.505 s 76% 1 3.387 s 1.789 s 47% 2 4.490 s 3.007 s 33% 3 6.830 s 4.207 s 38% Devnet blocks carry about one attestation group on average. Closes #442
leanVM keyed XMSS claim groups by epoch alone, so a proof could hold one message per slot. A block carrying two AttestationData for the same slot was unprovable and the proposer lost the slot: over eight hours of a three-client devnet that cost ethlambda 41 slots and lantern 186. gean lost none only because its block builder keeps one AttestationData per slot. leanVM 48a90420 keys groups by (epoch, message), so an epoch signed at under several messages is one group per message. Pin both glue crates to it. AggregationError::ConflictingMessages is gone, and with it Failure's -4 and its ffi.go reason. signature_claims() groups and sorts on (epoch, message) and can no longer fail, so five call sites drop their Option handling. -12 now counts claim groups rather than slots, matching upstream. TestType2RejectsTwoMessagesAtOneSlot asserted the old refusal; it becomes TestType2MergesTwoMessagesAtOneSlot, which merges two validators signing different messages at one slot and verifies the result, then checks the proof does not verify with the two messages exchanged. The block builder still keeps one AttestationData per slot. A node on the earlier rev rejects a two-group-at-one-epoch signer set in check_signer_set, during decode and before any verification, so dropping that filter while peers run the earlier rev would have them reject our blocks. It comes out once the devnet has moved. Refs leanEthereum/leanVM#278
Reverts 478004c, which kept one AttestationData per slot and none at the block's own slot. That rule existed because a leanVM proof carried one message per epoch and the epoch is the slot, so a block holding two distinct data at one slot could not be proved. leanVM 48a90420 keys claim groups by (epoch, message), so it can, and the rule now only drops honest votes. Validators disagreeing within a slot is ordinary: some have seen the slot's block and some have not, so they vote for different heads at the same slot. Those are exactly the votes the rule discarded, and exactly the ones justification needs when the network is split. The spec never asked for the rule. MAX_ATTESTATIONS_DATA caps the distinct data a block may carry and fork choice rejects a repeated AttestationData; neither says anything about two different data sharing a slot. Raised as leanEthereum/leanSpec#1209, still open. TestPlanAttestationsKeepsEveryDataAtOneSlot replaces the test of the removed rule: two data at one slot and one at the block's own slot are all planned, with no payload errors. A node on the earlier leanVM rejects a two-group-at-one-epoch signer set in check_signer_set, during decode and before any verification, so a peer that has not bumped will reject blocks built this way. Devnet runs stay between gean nodes until the other clients move.
Engine.Run drove every duty from time.NewTicker(800ms) created at process start. The period was right but the phase was whatever moment the process started at, so each node ran every interval's duties at boundary+phi for its whole life, phi being random per restart. On the devnet three of four gean nodes proposed and attested 600-790 ms late; attesting after the slot's block had arrived, they voted a newer head than the rest of the network, produced different AttestationData, and their votes mostly never joined the main aggregate. Ticks now come from alignedTicks, which re-derives each deadline from the wall clock relative to genesis. Each boundary is delivered at most once and never before the wall clock reaches it, because onTick keeps no per-interval guard and an early or repeated tick would re-run the previous interval's duties. Delivery stays a non-blocking send into a one-slot buffer, as with time.Ticker, so dispatch is unchanged. lean_tick_phase_seconds records how far into its interval each tick is handled, so a shifted clock shows on the dashboard instead of having to be inferred from log timestamps. Refs #451
A tick can be handled after the next boundary's tick is already queued, when the dispatch loop is blocked across a boundary. onTick derives the interval from the wall clock, so both ticks then run the same interval's duties, and produceAttestations signs a second attestation for the slot; if the head moved in between, the two carry different data under one XMSS epoch. onTick now claims the interval before running duties and drops a tick whose interval has already run. The key is the interval's start time, not TotalIntervals: that is 0 both before genesis and through genesis's first interval, so a node started before genesis would have skipped the genesis tick. Before genesis millisIntoSlot is 0 and each tick claims its own timestamp. The alignedTicks comment claimed the tick source alone prevented re-runs; it only ensures each boundary is delivered once. Refs #451
fix(node): anchor the duty ticker to genesis
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Moves gean to leanVM
022ec377, where XMSS hashes with BLAKE2s over a binary field instead of Poseidon over KoalaBear, and aggregation is oneaggregate()call. Based onmain, which now carries #439 and, through it, the Docker CPU-target fix from #434.feat(xmss)!: both glue crates depend on the rootleanvmcrate at one rev.hashsig-glue:key_gen_from_seedtakes an inclusive epoch range rather than a start and count, and returns(secret, public).signandverifytake their arguments in a new order. Signing stays deterministic. A compile-time check pins the SSZ sizes the Go types hard-code; they are unchanged (32 / 1208 / 32).multisig-glue: rebuilt onaggregate(). Proof bytes no longer carry the messages, slots or keys they prove, so every decode is given its claims, and the signer set is canonicalised in one place shared by prover and verifier. Split re-proves the Type-2 as a child, declaring only the kept group.ffi.go: merge and split take the message and slot of each group;proposal.goandrecovery.gobind them the wayverifyBlockSignaturesdoes.feat(blockbuilder): a leanVM proof carries at most one message per epoch, and the epoch is the slot. The planner keeps one AttestationData per slot, and none at the block's own slot, which the proposer's signature holds. The current spec allows two data per slot, so this departs from it until the spec settles how the new proof system handles that case.build:+aeson aarch64 (PMULL is gated behind it, and without it the binary-field prover falls back to scalar code). The Dockerfile no longer stages leanVM.pysources into the runtime image: the guest is compiled frominclude_str!and nothing on the production path reads a file.perf(docker):MALLOC_CONF=background_thread:true. jemalloc purges freed pages only when the arena that freed them is used again, unless its background threads are on (they are off by default), so proof scratch spread across the prover's worker threads stayed resident between proofs.fix(xmss): the proving calls return a distinct status for each leanVMAggregationError, an undecodable input proof, a missing split target and a caught panic, instead of-1for all of them, andffi.gowrapsErrAggregationFailedwith the reason. One block proof in 14,396 proposals failed on the devnet with only "signature aggregation failed" to go on.perf(proposal): the block-proof merge takes the proposer's signature raw instead of proving it alone first and merging that proof (#442). The unifiedaggregate()accepts proofs and raw signatures together, and the proof carries the same claims, so verification is unchanged.Not interoperable yet
hash-sig-cli:0.5.0generates Poseidon-line keys. A hash-sig-cli port to022ec377exists and has been checked against this branch.eca701ef) still specifies Poseidon-line, 52-byte public keys. Spec Tests CI has failed on every PR since 2026-08-29 for that reason, in Go size checks this PR does not touch, and will fail here the same way.Draft until it has run on a gean-only devnet.
Associated Issue
Closes #442.
Test Plan
make test-ffi: keygen, signing, aggregation, merge → verify → split → verify, the block SSZ round trip, and a new test that two messages at one slot are refused by the merge.make testandmake lintpass. Each commit was checked on its own with only its files present.docker buildx build --check: no warnings; the Dockerfile's build step passes the flags through to cargo.Type1InputrepeatsMessageBinding's fields, andblockProofClaimsduplicates the bindingsverifyBlockSignaturesbuilds (pre-existing duplication this PR extends).Checklist
CONTRIBUTING.mdis not present)