Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bd76661
feat(aggregation): add subnet reach and window primitives
MegaRedHand Sep 9, 2026
a7c75fb
refactor(aggregation): tighten the subnet window primitives
MegaRedHand Sep 9, 2026
c0ca3e2
feat(aggregation): score child selection through a subnet window
MegaRedHand Sep 9, 2026
c49f5e0
docs(aggregation): correct why child selection covers out-of-window ids
MegaRedHand Sep 9, 2026
234a549
feat(aggregation): add the redundancy-skipping width rotation
MegaRedHand Sep 9, 2026
9ed9ab4
refactor(aggregation): floor the rotation width and document its edges
MegaRedHand Sep 9, 2026
0018e49
feat(aggregation): derive a per-candidate subnet window
MegaRedHand Sep 9, 2026
02a738d
fix(aggregation): derive the duty subnet from the subscription set
MegaRedHand Sep 9, 2026
68b8f4b
feat(blockchain): carry the aggregation duty subnet on the actor
MegaRedHand Sep 9, 2026
f1a9254
fix(aggregation): reduce the duty subnet before the width rotation
MegaRedHand Sep 9, 2026
05ab79b
feat(cli): add --skip-redundant-aggregation and duty-subnet resolution
MegaRedHand Sep 9, 2026
0641ddb
docs(cli): document the duty subnet and log how it resolved
MegaRedHand Sep 9, 2026
57b3233
docs: document the aggregation window metrics
MegaRedHand Sep 9, 2026
6041332
test(aggregation): pin the four-aggregator reduction tree
MegaRedHand Sep 9, 2026
e5825f0
docs: shorten the aggregation metric rows and correct the pinned reading
MegaRedHand Sep 9, 2026
5653ae7
test(aggregation): make the duty-subnet reduction test discriminate
MegaRedHand Sep 9, 2026
0f41273
fix(aggregation): fall back to the full window when a window declines…
MegaRedHand Sep 9, 2026
ee8ea8d
feat(cli): warn when the redundancy-skipping rotation cannot rotate
MegaRedHand Sep 9, 2026
b4ef8fa
docs(aggregation): describe the window's real cadence
MegaRedHand Sep 9, 2026
1782908
docs: describe subnet-windowed aggregation in the architecture guide
MegaRedHand Sep 9, 2026
e58eb20
feat(aggregation): anchor the subnet window on the duty subnet's best…
MegaRedHand Sep 10, 2026
aab43b1
Merge branch 'main' into feat/subnet-windowed-aggregation
MegaRedHand Sep 10, 2026
255d75f
Merge branch 'main' into feat/subnet-windowed-aggregation
MegaRedHand Sep 15, 2026
e98b4db
refactor(aggregation): cut duplicated scans and clones from the windo…
MegaRedHand Sep 15, 2026
61e21ab
fix(cli): reject an --aggregate-subnet-ids value the committee has no…
MegaRedHand Sep 16, 2026
f33b308
fix(aggregation): count window fallbacks that recover a merge, not ev…
MegaRedHand Sep 16, 2026
8ac6dfc
docs(aggregation): state the window's two unenforced preconditions
MegaRedHand Sep 16, 2026
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
37 changes: 37 additions & 0 deletions bin/ethlambda/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,45 @@ pub(crate) struct NodeOptions {
pub(crate) attestation_committee_count: Option<u64>,
/// Subnet IDs this aggregator should subscribe to (comma-separated).
/// Requires --is-aggregator. Defaults to the subnets of the node's validators.
/// Every ID must be below --attestation-committee-count; the node refuses to
/// start otherwise, since a higher ID names a topic no validator publishes on.
///
/// The first ID is also this node's aggregation duty subnet: where its
/// aggregation window starts, and what --skip-redundant-aggregation
/// rotates ownership over. Order matters, so give co-located aggregators
/// different first IDs. Unset, the duty subnet falls back to the lowest
/// subscribed subnet, which is the same value on every node whose
/// validators span all subnets.
#[arg(long, value_delimiter = ',', requires = "is_aggregator")]
pub(crate) aggregate_subnet_ids: Option<Vec<u64>>,
/// Sit out aggregation candidates whose level another duty subnet owns
/// this slot. Requires --is-aggregator.
///
/// By default every aggregator merges proofs for a window of subnets
/// starting at its duty subnet, and windows belonging to neighbouring duty
/// subnets overlap, so some prover work is duplicated. With this flag an
/// aggregator skips a candidate whose width it does not own in the current
/// slot and spends that job on the next-best attestation data instead. The
/// owner rotates with the slot, so no node is permanently the one sitting
/// out, and the narrowest width is owned by everyone, so a candidate whose
/// pool holds nothing on this node's subnet, which is the raw-signature
/// case, is never skipped.
///
/// Worth enabling when leanVM prover CPU is the bottleneck on co-located
/// aggregators.
///
/// Deployment precondition: give every subnet below
/// --attestation-committee-count an aggregator holding it as its duty
/// subnet. Ownership is `duty_subnet % width == slot % width`, so on a
/// sparser placement a width can have no owner at all while every
/// configured node is healthy. With duty subnets {0, 2} at committee count
/// 4, nothing owns width 4 in an odd slot, and since this flag also
/// disables the full-width fallback, that merge level is simply dropped
/// for the slot. The narrower levels still run and the next slot rotates to
/// a different owner, but the loss is structural, not just the cost of a
/// node that is down or late.
#[arg(long, default_value = "false", requires = "is_aggregator")]
pub(crate) skip_redundant_aggregation: bool,
/// Directory for RocksDB storage
#[arg(long, default_value = "./data")]
pub(crate) data_dir: PathBuf,
Expand Down
149 changes: 148 additions & 1 deletion bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
static malloc_conf: &[u8] = b"prof:true,prof_active:true,lg_prof_sample:19\0";

use std::{
collections::{BTreeMap, HashMap},
collections::{BTreeMap, HashMap, HashSet},
net::{IpAddr, SocketAddr},
path::{Path, PathBuf},
sync::Arc,
Expand Down Expand Up @@ -217,6 +217,12 @@ async fn run_node(options: NodeOptions) -> eyre::Result<()> {
attestation_committee_count,
"Loaded attestation committee count"
);
// Checked here rather than in clap: the committee count is only known once
// the CLI flag and the validator config have both been consulted.
validate_aggregate_subnet_ids(
options.aggregate_subnet_ids.as_deref(),
attestation_committee_count,
)?;
ethlambda_blockchain::metrics::set_attestation_committee_count(attestation_committee_count);

let bootnodes = read_bootnodes(&bootnodes_path)?;
Expand Down Expand Up @@ -279,12 +285,34 @@ async fn run_node(options: NodeOptions) -> eyre::Result<()> {
// receiver-count guard in `emit` makes every emission a no-op.
let events = EventBus::default();

let aggregation_duty_subnet = resolve_aggregation_duty_subnet(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duty subnet is later reduced modulo committee_count in window_for_candidate, but the subscription set built at line 263 keeps the raw --aggregate-subnet-ids values and the swarm subscribes to those raw topics.

With --attestation-committee-count 4 --aggregate-subnet-ids 5 this node subscribes to topic 5, which no validator publishes on, and aggregates as duty subnet 1, which it does not listen to. The startup log prints 5, so the mismatch is invisible to the operator.

Rather than reducing silently, reject any --aggregate-subnet-ids value at or above the committee count here, next to the existing attestation_committee_count >= 1 check. That fixes the pre-existing dead subscription too, and the modulo in window_for_candidate plus window_for_candidate_reduces_an_out_of_range_duty_subnet can then go.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the silent reduction was the wrong call. 61e21ab adds validate_aggregate_subnet_ids, called from run_node right after the committee count resolves (it cannot live in clap, since the count comes from the flag or validator-config.yaml). Every id is checked, not just the first: only the first becomes the duty subnet, but all of them become subscriptions. That closes the pre-existing dead subscription too.

One deviation from your suggestion: I kept the modulo in window_for_candidate and its test. AggregationWindowConfig and snapshot_aggregation_inputs are pub, so the blockchain crate cannot lean on the binary's validation, and SubnetWindow::new reduces start regardless. Dropping the reduction only in window_for_candidate would leave the ownership test running on the raw value while the window ran on the reduced one, which is precisely the desync that test pins. So it is all-or-nothing, and three lines guarding a public API seemed the better side to land on. The comment there now says the binary is what actually enforces it.

options.aggregate_subnet_ids.as_deref(),
&subscribed_subnets,
);
info!(
aggregation_duty_subnet,
assigned = options.aggregate_subnet_ids.is_some(),
"Resolved aggregation duty subnet"
);
if options.skip_redundant_aggregation && options.aggregate_subnet_ids.is_none() {
warn!(
aggregation_duty_subnet,
"--skip-redundant-aggregation is set but the duty subnet was derived, not assigned: \
every co-located aggregator whose validators span all subnets derives the same duty \
subnet, so they will sit out in lockstep in the same slot instead of taking turns, \
and the widest level gets no producer at all in most slots. Give each aggregator a \
distinct first --aggregate-subnet-ids value to fix this."
);
}

let blockchain_config = BlockChainConfig {
aggregator: aggregator.clone(),
sync_status_controller: sync_status.clone(),
attestation_committee_count,
gate_duties: !options.disable_duty_sync_gate,
subscribed_subnets: subscribed_subnets.clone(),
aggregation_duty_subnet,
skip_redundant_aggregation: options.skip_redundant_aggregation,
proposer_config: ProposerConfig {
enable_proposer_aggregation: options.enable_proposer_aggregation,
max_attestations_per_block: options.max_attestations_per_block,
Expand Down Expand Up @@ -818,13 +846,132 @@ async fn fetch_initial_state(
Ok(store)
}

/// Reject an `--aggregate-subnet-ids` value that names no subnet.
///
/// The flag feeds two consumers that read an out-of-range value differently:
/// the P2P swarm subscribes to the raw id (`attestation_subscription_subnets`
/// passes it through), while the aggregation window reduces it modulo the
/// committee count. `--attestation-committee-count 4 --aggregate-subnet-ids 5`
/// therefore subscribes to a topic no validator publishes on and aggregates as
/// duty subnet 1, which the node does not listen to, with the startup log
/// showing 5 either way. Refusing to start is the only reading of that
/// configuration that cannot silently mean something else.
fn validate_aggregate_subnet_ids(
assigned_subnet_ids: Option<&[u64]>,
attestation_committee_count: u64,
) -> eyre::Result<()> {
let out_of_range = assigned_subnet_ids
.unwrap_or_default()
.iter()
.find(|&&id| id >= attestation_committee_count);
match out_of_range {
None => Ok(()),
Some(id) => Err(eyre::eyre!(
"--aggregate-subnet-ids value {id} is not a subnet: ids must be below \
attestation_committee_count ({attestation_committee_count})"
)),
}
}

/// The subnet this node is responsible for when scoring recursive aggregation.
///
/// Operators assign it explicitly via --aggregate-subnet-ids so co-located
/// aggregators land on different subnets and merge different proofs. Without
/// an assignment, fall back to the lowest subnet this node already listens
/// on: `min` rather than an arbitrary pick because `HashSet` iteration order
/// is not stable and the duty subnet must be.
fn resolve_aggregation_duty_subnet(
assigned_subnet_ids: Option<&[u64]>,
subscribed_subnets: &HashSet<u64>,
) -> u64 {
assigned_subnet_ids
.and_then(|ids| ids.first().copied())
.or_else(|| subscribed_subnets.iter().copied().min())
.unwrap_or(0)
}

#[cfg(test)]
mod tests {
use super::*;
use ethlambda_storage::backend::InMemoryBackend;
use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT;
use ethlambda_types::genesis::GenesisValidatorEntry;

/// The duty subnet is the first explicitly assigned subnet, so an operator
/// can place co-located aggregators on different subnets deliberately.
#[test]
fn duty_subnet_prefers_the_first_assigned_id() {
let subscribed = HashSet::from([0u64, 1, 2, 3]);
assert_eq!(
resolve_aggregation_duty_subnet(Some(&[3, 1]), &subscribed),
3,
"the first assigned id wins, not the lowest"
);
}

/// With no assignment, the lowest subscribed subnet is used, which is
/// stable across restarts unlike an arbitrary pick from the set.
#[test]
fn duty_subnet_falls_back_to_the_lowest_subscribed() {
let subscribed = HashSet::from([5u64, 2]);
assert_eq!(resolve_aggregation_duty_subnet(None, &subscribed), 2);
}

/// A node with nothing assigned and nothing subscribed still needs an
/// answer; subnet 0 always exists.
#[test]
fn duty_subnet_defaults_to_zero_with_nothing_to_go_on() {
assert_eq!(resolve_aggregation_duty_subnet(None, &HashSet::new()), 0);
}

/// An empty list is no assignment at all, so the subscription fallback
/// still applies rather than the last-resort zero.
#[test]
fn duty_subnet_treats_an_empty_assignment_as_no_assignment() {
assert_eq!(
resolve_aggregation_duty_subnet(Some(&[]), &HashSet::from([4u64])),
4
);
}

/// An id at or above the committee count names a topic no validator
/// publishes on, and would be reduced to a different subnet by the
/// aggregation window, so the node refuses it rather than running with
/// its subscriptions and its duty subnet disagreeing.
#[test]
fn an_out_of_range_aggregate_subnet_id_is_rejected() {
let err = validate_aggregate_subnet_ids(Some(&[5]), 4)
.expect_err("subnet 5 does not exist at committee count 4");
let message = err.to_string();
assert!(message.contains('5'), "names the offending id: {message}");
assert!(message.contains('4'), "names the bound: {message}");
}

/// The check covers every id, not just the first: only the first becomes
/// the duty subnet, but all of them become gossip subscriptions.
#[test]
fn an_out_of_range_aggregate_subnet_id_is_rejected_past_the_first() {
assert!(validate_aggregate_subnet_ids(Some(&[0, 9]), 4).is_err());
}

/// The bound is exclusive: subnets run 0..committee_count.
#[test]
fn in_range_aggregate_subnet_ids_are_accepted() {
assert!(validate_aggregate_subnet_ids(Some(&[0, 3]), 4).is_ok());
assert!(
validate_aggregate_subnet_ids(Some(&[0]), 1).is_ok(),
"subnet 0 is the only subnet at a committee count of 1"
);
}

/// Nothing assigned is nothing to validate; the duty subnet is then
/// derived from subscriptions, which are already in range by construction.
#[test]
fn an_unset_or_empty_assignment_passes_validation() {
assert!(validate_aggregate_subnet_ids(None, 4).is_ok());
assert!(validate_aggregate_subnet_ids(Some(&[]), 4).is_ok());
}

/// Validator-config snippet matching `lean-quickstart`'s ansible-devnet
/// where networks share a non-default committee count.
const VC_WITH_COMMITTEE_COUNT: &str = r#"
Expand Down
Loading
Loading