Skip to content

feat(xmss)!: move to leanVM's BLAKE2s line (022ec377) - #441

Draft
dimka90 wants to merge 11 commits into
mainfrom
feat/leanvm-blake2s
Draft

dimka90 wants to merge 11 commits into
mainfrom
feat/leanvm-blake2s

Conversation

@dimka90

@dimka90 dimka90 commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Moves gean to leanVM 022ec377, where XMSS hashes with BLAKE2s over a binary field instead of Poseidon over KoalaBear, and aggregation is one aggregate() call. Based on main, which now carries #439 and, through it, the Docker CPU-target fix from #434.

feat(xmss)!: both glue crates depend on the root leanvm crate at one rev.

  • 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; they are unchanged (32 / 1208 / 32).
  • 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 take the message and slot of each group; proposal.go and recovery.go bind them the way verifyBlockSignatures does.
  • The Poseidon permutation export and its spec test are removed: leanVM no longer contains Poseidon.

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: +aes on aarch64 (PMULL is gated behind it, and without it the binary-field prover falls back to scalar code). The Dockerfile no longer stages leanVM .py sources into the runtime image: the guest is compiled from include_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 leanVM AggregationError, an undecodable input proof, a missing split target and a caught panic, instead of -1 for all of them, and ffi.go wraps ErrAggregationFailed with 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 unified aggregate() accepts proofs and raw signatures together, and the proof carries the same claims, so verification is unchanged.

Not interoperable yet

  • No other client runs this leanVM rev, and keys, signatures and proofs do not verify across the Poseidon line. Validator keys must come from a keygen on the same rev; hash-sig-cli:0.5.0 generates Poseidon-line keys. A hash-sig-cli port to 022ec377 exists and has been checked against this branch.
  • The pinned leanSpec (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 test and make lint pass. Each commit was checked on its own with only its files present.
  • New blockbuilder test covering the one-data-per-slot rule.
  • gean loaded keys generated by the ported hash-sig-cli, signed with them (re-signing is byte-identical), verified, aggregated, verified the aggregate with the keys in reverse order, and rejected it against a wrong message.
  • docker buildx build --check: no warnings; the Dockerfile's build step passes the flags through to cargo.
  • Reviewed with the lean-review workflow: two report-only findings in consensus-critical code, not applied: Type1Input repeats MessageBinding's fields, and blockProofClaims duplicates the bindings verifyBlockSignatures builds (pre-existing duplication this PR extends).
  • 16-node gean-only devnet on this branch, 4 aggregators, 16 validators: finalizing with justified − finalized at 3.4–4.6, aggregation proof 1.21 s, block proof 2.66 s, aggregate verify 125 ms; no errors or restarts over 14 hours.
  • Block-proof paths measured locally, interleaved over the same inputs, 3 runs each: 0 attestation groups 2.114 s → 0.505 s (−76%), 1 group 3.387 s → 1.789 s (−47%), 2 groups 4.490 s → 3.007 s (−33%), 3 groups 6.830 s → 4.207 s (−38%). Devnet blocks carry about one group on average. New FFI test: attestation proofs plus a raw proposer signature merge and verify, and fail against a different proposer message.
  • Memory setting, A/B on the same devnet (one node restarted with it, fifteen without), 10 hours: 268 MB average against 729 MB, floor 185 MB against 546–662 MB; block proof 2.632 s against 2.629 s.

Checklist

  • Repository guidelines reviewed (CONTRIBUTING.md is not present)
  • Self-review completed
  • Tests added that cover the change
  • New and existing unit tests pass locally

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
dimka90 changed the base branch from fix/docker-cpu-target to main September 19, 2026 20:14
@dimka90
dimka90 force-pushed the feat/leanvm-blake2s branch from 2685557 to 0da0e9e Compare September 19, 2026 20:14
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.
dimka90 and others added 3 commits September 25, 2026 09:40
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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(proposal): fold the proposer signature into the block-proof merge

2 participants