Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 14 additions & 28 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,13 @@ WORKDIR /app
COPY xmss/rust/ xmss/rust/

# Detect the build-stage architecture: legacy builders do not populate TARGETARCH.
# Match make ffi's Haswell/AVX2 baseline on x86_64; leave arm64 flags unchanged.
# Same flags as make ffi: Haswell on x86_64, +aes (PMULL) on aarch64.
RUN cd xmss/rust && \
if [ "$(uname -m)" = "x86_64" ]; then \
CARGO_ENCODED_RUSTFLAGS="-Ctarget-cpu=haswell" cargo build --profile multisig-release --locked; \
else \
cargo build --profile multisig-release --locked; \
fi

# Stage leanVM Python sources at the exact checkout path the binary expects.
# The lean_compiler resolves .py files via CARGO_MANIFEST_DIR baked at compile time;
# on arm64 the pre-committed bytecode cache misses and triggers a recompile from source.
# Match the checkout by crate, not by pinned rev: cargo names the rev subdir after
# the leanVM commit, so a hardcoded short hash breaks on every dependency bump.
RUN CHECKOUT_DIR=$(ls -d /root/.cargo/git/checkouts/leanvm-*/*/crates/rec_aggregation | head -1 | sed 's|/crates/rec_aggregation||') && \
mkdir -p /leanvm-staged && \
echo "$CHECKOUT_DIR" > /leanvm-staged/.checkout_root && \
cp -r "$CHECKOUT_DIR/crates/rec_aggregation" /leanvm-staged/rec_aggregation && \
cp -r "$CHECKOUT_DIR/crates/lean_compiler" /leanvm-staged/lean_compiler
case "$(uname -m)" in \
x86_64) CARGO_ENCODED_RUSTFLAGS="-Ctarget-cpu=haswell" cargo build --profile multisig-release --locked ;; \
aarch64) CARGO_ENCODED_RUSTFLAGS="-Ctarget-feature=+aes" cargo build --profile multisig-release --locked ;; \
*) cargo build --profile multisig-release --locked ;; \
esac

# Copy Go module files for dependency caching
COPY go.mod go.sum ./
Expand Down Expand Up @@ -68,17 +57,6 @@ LABEL org.opencontainers.image.ref.name=$GIT_BRANCH
COPY --from=builder /app/bin/gean /usr/local/bin/
COPY --from=builder /app/bin/keygen /usr/local/bin/

# leanVM's lean_compiler reads .py files at runtime when the embedded
# cached_bytecode.bin fingerprint doesn't match the build target (arm64 builds
# hit this because the repo's cache is x86-only). Restore the Python sources
# at the exact CARGO_MANIFEST_DIR path baked into the binary at compile time.
COPY --from=builder /leanvm-staged/ /tmp/leanvm-staged/
RUN CHECKOUT_ROOT=$(cat /tmp/leanvm-staged/.checkout_root) && \
mkdir -p "$CHECKOUT_ROOT/crates" && \
cp -r /tmp/leanvm-staged/rec_aggregation "$CHECKOUT_ROOT/crates/" && \
cp -r /tmp/leanvm-staged/lean_compiler "$CHECKOUT_ROOT/crates/" && \
rm -rf /tmp/leanvm-staged


# Prove on jemalloc, not glibc malloc. The prover frees its scratch after every
# proof, but glibc keeps it: most lands in the main heap, which only shrinks from
Expand All @@ -94,6 +72,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends libjemalloc2 \
&& ldconfig -p | grep -q 'libjemalloc\.so\.2'
ENV LD_PRELOAD=libjemalloc.so.2

# jemalloc purges freed pages only when the arena that freed them is used again,
# unless its background threads are on, and they are off by default. A proof
# spreads gigabytes across the prover's worker threads and then leaves those
# arenas idle, so hundreds of megabytes stayed resident between proofs. Devnet,
# 16 nodes over 10 hours, the only difference being this setting: 268 MB average
# against 729 MB, with identical proof and verification times.
ENV MALLOC_CONF=background_thread:true

# Keep the Go heap tight so the XMSS prover's transient multi-GB proving
# peaks (allocated by the Rust arena, invisible to the Go GC) land on free
# memory instead of an uncollected heap. Operators can override.
Expand Down
14 changes: 9 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ LEAN_SPEC_COMMIT_HASH := eca701efeb5931010fe63925cd203c9ee55b2dbc
help: ## Show help for each Makefile recipe
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

# leanVM's prover does binary-field arithmetic with carryless multiplication. On x86 the
# Haswell baseline provides PCLMULQDQ; on aarch64 PMULL is gated behind the `aes` target
# feature, which Linux aarch64 targets do not enable by default. Without it the prover
# falls back to scalar code.
ffi: ## Build XMSS FFI glue libraries (hashsig-glue + multisig-glue)
@cd xmss/rust && \
if [ "$$(uname -m)" = "x86_64" ]; then \
CARGO_ENCODED_RUSTFLAGS="-Ctarget-cpu=haswell" cargo build --profile multisig-release --locked; \
else \
cargo build --profile multisig-release --locked; \
fi
case "$$(uname -m)" in \
x86_64) CARGO_ENCODED_RUSTFLAGS="-Ctarget-cpu=haswell" cargo build --profile multisig-release --locked ;; \
aarch64|arm64) CARGO_ENCODED_RUSTFLAGS="-Ctarget-feature=+aes" cargo build --profile multisig-release --locked ;; \
*) cargo build --profile multisig-release --locked ;; \
esac

build: ffi ## Build gean and keygen binaries
@mkdir -p bin
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,14 @@ via the environment if your budget differs.

Gean tracks Lean Consensus devnet-5. Consensus fixtures are generated from
`leanSpec@eca701efeb5931010fe63925cd203c9ee55b2dbc`, which pins
`lean-multisig-py` v0.0.9. The XMSS FFI builds against
leanVM `e2592df4e30fdddbbf8ae26a333116c68cec7026`.
`lean-multisig-py` v0.0.9. The XMSS FFI builds against leanVM
`48a904208d682848dac0e18ef8b01ebfc40df9ad`, the BLAKE2s line: keys and proofs
from the earlier Poseidon line do not verify against it, and validator keys must
come from a keygen built on the same rev.

`LEAN_SPEC_COMMIT_HASH` in the [`Makefile`](Makefile) is the source of truth for
the spec version; the leanVM rev is pinned in
[`xmss/rust/hashsig-glue/Cargo.toml`](xmss/rust/hashsig-glue/Cargo.toml) and
[`xmss/rust/multisig-glue/Cargo.toml`](xmss/rust/multisig-glue/Cargo.toml).

## Philosophy
Expand Down
46 changes: 46 additions & 0 deletions internal/blockbuilder/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,3 +515,49 @@ func TestPlanAttestationsDoesNotReportSkippedPayloadsWhenFull(t *testing.T) {
t.Fatalf("payload errors=%d, want 0", len(plan.payloadErrors))
}
}

// Validators that disagree within a slot cast distinct AttestationData at that slot, and a
// claim group is an (epoch, message) pair, so the block carries them all. The block's own
// slot is no exception: the proposer's signature over the block root is its own group there.
func TestPlanAttestationsKeepsEveryDataAtOneSlot(t *testing.T) {
headState, parentRoot, data, dataRoot := postHeaderVoteInput(t)
root1 := [32]byte{0x11}

// Same slot as data, different head: a second message at a slot the block already covers.
rival := *data
rival.Head = &types.Checkpoint{Slot: 1, Root: root1}
// The block's own slot, which the proposer's signature also holds.
atBlockSlot := *data
atBlockSlot.Slot = 3

plan, err := planAttestations(Input{
HeadState: headState,
Slot: 3,
ProposerIndex: 0,
ParentRoot: parentRoot,
KnownBlockRoots: RootSet{root1: true, parentRoot: true},
Payloads: []AttestationPayload{
{DataRoot: dataRoot, Data: data, Proofs: []*types.SingleMessageAggregate{mockProof([]uint64{0})}},
{DataRoot: hashAttestationData(t, &rival), Data: &rival, Proofs: []*types.SingleMessageAggregate{mockProof([]uint64{0})}},
{DataRoot: hashAttestationData(t, &atBlockSlot), Data: &atBlockSlot, Proofs: []*types.SingleMessageAggregate{mockProof([]uint64{0})}},
},
})
if err != nil {
t.Fatalf("plan attestations: %v", err)
}
if len(plan.attestations) != 3 {
t.Fatalf("planned %d attestations, want 3", len(plan.attestations))
}
if len(plan.payloadErrors) != 0 {
t.Fatalf("payload errors=%d, want 0: %v", len(plan.payloadErrors), plan.payloadErrors)
}
atSlot2 := 0
for _, att := range plan.attestations {
if att.Data.Slot == 2 {
atSlot2++
}
}
if atSlot2 != 2 {
t.Fatalf("attestations at slot 2 = %d, want 2", atSlot2)
}
}
17 changes: 4 additions & 13 deletions internal/blockprocessor/block_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func buildBenchSignedBlock(n int) (*types.SignedBlock, error) {
}
bits := types.BitlistFromIndices([]uint64{0})
atts[i] = &types.AggregatedAttestation{AggregationBits: bits, Data: data}
inputs = append(inputs, xmss.Type1Input{Pubkeys: []xmss.CPubKey{benchPubkey}, Proof: proof})
inputs = append(inputs, xmss.Type1Input{Pubkeys: []xmss.CPubKey{benchPubkey}, Proof: proof, Message: root, Slot: uint32(data.Slot)})
}

block := &types.Block{
Expand All @@ -136,18 +136,9 @@ func buildBenchSignedBlock(n int) (*types.SignedBlock, error) {
if err != nil {
return nil, err
}
proposerProof, err := xmss.AggregateSignatures(
[]xmss.CPubKey{benchPubkey},
[]xmss.CSig{signature},
blockRoot,
blockSlot,
)
xmss.FreeSignature(signature)
if err != nil {
return nil, err
}
inputs = append(inputs, xmss.Type1Input{Pubkeys: []xmss.CPubKey{benchPubkey}, Proof: proposerProof})
proof, err := xmss.MergeType1Proofs(inputs)
defer xmss.FreeSignature(signature)
proposer := xmss.RawSignature{Pubkey: benchPubkey, Signature: signature, Message: blockRoot, Slot: blockSlot}
proof, err := xmss.MergeType1Proofs(inputs, []xmss.RawSignature{proposer})
if err != nil {
return nil, err
}
Expand Down
9 changes: 9 additions & 0 deletions internal/metrics/histograms.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ var (
Help: "Elapsed time between clock ticks in seconds",
Buckets: []float64{0.4, 0.6, 0.75, 0.8, 0.805, 0.81, 0.815, 0.82, 0.825, 0.85, 0.9, 1.0, 1.2, 1.6},
})
// metricTickPhase is where in its interval each tick's duties start. An
// aligned clock reads near zero; a constant offset means the tick schedule
// is shifted and every duty runs that much late. Dispatch-loop delay before
// the tick is handled counts too, since that is when the duties really run.
metricTickPhase = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "lean_tick_phase_seconds",
Help: "Offset of each tick into its interval, measured when the tick is handled",
Buckets: []float64{0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8},
})
// metricDispatchEventDuration times each case of the dispatch select, so a
// slow handler can be attributed rather than only observed as a late tick.
// Buckets run well past a slot: the point is to size a stall, and the
Expand Down
1 change: 1 addition & 0 deletions internal/metrics/observe.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func ObserveForkChoiceReorgDepth(depth float64) {
func ObserveTickIntervalDuration(seconds float64) {
observeNonNegative(metricTickIntervalDuration, seconds)
}
func ObserveTickPhase(seconds float64) { observeNonNegative(metricTickPhase, seconds) }

// ObserveDispatchEvent records how long one dispatch-loop event took.
func ObserveDispatchEvent(event string, seconds float64) {
Expand Down
15 changes: 15 additions & 0 deletions internal/node/clock.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ func (e *Engine) currentInterval(timestampMs uint64) uint64 {
return types.CurrentInterval(e.Store.Config().GenesisTime, timestampMs)
}

// claimInterval reports whether the interval containing timestampMs has not
// had its duties run yet, and marks it as run. When the dispatch loop falls
// behind, two ticks can be handled inside one interval; running its duties a
// second time would sign a second attestation for the slot. Before genesis
// millisIntoSlot is 0, so each pre-genesis tick claims its own timestamp and
// none can shadow the genesis interval.
func (e *Engine) claimInterval(timestampMs uint64) bool {
start := timestampMs - e.millisIntoSlot(timestampMs)%types.MillisecondsPerInterval
if start <= e.lastIntervalStartMs {
return false
}
e.lastIntervalStartMs = start
return true
}

func (e *Engine) millisIntoSlot(timestampMs uint64) uint64 {
if e == nil || e.Store == nil {
return 0
Expand Down
9 changes: 5 additions & 4 deletions internal/node/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ type Engine struct {
// goroutine precisely so it still reports while the dispatch loop is blocked.
lastTickMs atomic.Int64

// lastIntervalStartMs is the start of the interval whose duties onTick
// last ran; see claimInterval.
lastIntervalStartMs uint64

warnedMissingJustified [32]byte

// maxSeenGossipSlot is the highest plausible slot heard on gossip, whether
Expand Down Expand Up @@ -176,12 +180,9 @@ func (e *Engine) WaitForStorageWorkers() {
func (e *Engine) Run(ctx context.Context) {
e.initMetrics()

ticker := time.NewTicker(types.MillisecondsPerInterval * time.Millisecond)
defer ticker.Stop()

e.startWorkers(ctx)

logger.Info(logger.Node, "started")
e.onTick()
e.dispatch(ctx, ticker.C)
e.dispatch(ctx, alignedTicks(ctx, e.Store.Config().GenesisTime))
}
43 changes: 19 additions & 24 deletions internal/node/proposal.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,16 +208,15 @@ func (e *Engine) mergeBlockProof(
proposerKey *xmss.ValidatorKeyPair,
proposerSignature [types.SignatureSize]byte,
) ([]byte, error) {
return e.mergeBlockProofWithProvers(block, attestationProofs, proposerKey, proposerSignature, xmss.AggregateSignatures, xmss.MergeType1Proofs)
return e.mergeBlockProofWithProver(block, attestationProofs, proposerKey, proposerSignature, xmss.MergeType1Proofs)
}

func (e *Engine) mergeBlockProofWithProvers(
func (e *Engine) mergeBlockProofWithProver(
block *types.Block,
attestationProofs []*types.SingleMessageAggregate,
proposerKey *xmss.ValidatorKeyPair,
proposerSignature [types.SignatureSize]byte,
wrap func([]xmss.CPubKey, []xmss.CSig, [32]byte, uint32) ([]byte, error),
merge func([]xmss.Type1Input) ([]byte, error),
merge func([]xmss.Type1Input, []xmss.RawSignature) ([]byte, error),
) ([]byte, error) {
if block == nil || block.Body == nil || len(block.Body.Attestations) != len(attestationProofs) {
return nil, fmt.Errorf("attestation proof count mismatch")
Expand All @@ -230,7 +229,7 @@ func (e *Engine) mergeBlockProofWithProvers(
return nil, fmt.Errorf("parent state missing")
}

inputs := make([]xmss.Type1Input, 0, len(attestationProofs)+1)
inputs := make([]xmss.Type1Input, 0, len(attestationProofs))
for i, proof := range attestationProofs {
if proof == nil {
return nil, fmt.Errorf("attestation proof %d missing", i)
Expand All @@ -246,7 +245,12 @@ func (e *Engine) mergeBlockProofWithProvers(
}
keys = append(keys, key)
}
inputs = append(inputs, xmss.Type1Input{Pubkeys: keys, Proof: proof.Proof})
data := block.Body.Attestations[i].Data
root, err := data.HashTreeRoot()
if err != nil {
return nil, fmt.Errorf("attestation %d data root: %w", i, err)
}
inputs = append(inputs, xmss.Type1Input{Pubkeys: keys, Proof: proof.Proof, Message: root, Slot: uint32(data.Slot)})
}

signature, err := xmss.ParseSignature(proposerSignature[:])
Expand All @@ -261,26 +265,17 @@ func (e *Engine) mergeBlockProofWithProvers(
if e.Store.Head() != block.ParentRoot {
return nil, errStaleProposal
}
wrapStart := time.Now()
proposerProof, err := wrap(
[]xmss.CPubKey{proposerKey.PublicKey()},
[]xmss.CSig{signature},
blockRoot,
uint32(block.Slot),
)
metrics.ObserveProposalStageDuration("signature_proof", time.Since(wrapStart).Seconds())
if err != nil {
return nil, err
}
inputs = append(inputs, xmss.Type1Input{
Pubkeys: []xmss.CPubKey{proposerKey.PublicKey()},
Proof: proposerProof,
})
if e.Store.Head() != block.ParentRoot {
return nil, errStaleProposal
// The proposer's signature goes into the merge raw. Proving it alone first and
// merging that proof costs a whole extra proof, the most expensive part of a
// proposal after the merge itself.
proposer := xmss.RawSignature{
Pubkey: proposerKey.PublicKey(),
Signature: signature,
Message: blockRoot,
Slot: uint32(block.Slot),
}
mergeStart := time.Now()
proof, err := merge(inputs)
proof, err := merge(inputs, []xmss.RawSignature{proposer})
metrics.ObserveProposalStageDuration("merge", time.Since(mergeStart).Seconds())
return proof, err
}
Expand Down
Loading
Loading