From 87b85b993a9f6669e857bf967192ed5f4e9012c3 Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Tue, 11 Aug 2026 22:18:40 -0700 Subject: [PATCH 1/9] spike --- Cargo.lock | 3 + chain/src/engine.rs | 95 ++++-------- chain/src/lib.rs | 6 - deploy.sh | 91 ++++++++++++ deploy/Dockerfile | 5 +- deploy/README.md | 34 +++-- deploy/justfile | 14 +- deploy/src/main.rs | 232 +++++++++++++++++++++++------- explorer/public/index.html | 3 +- explorer/public/runtime-config.js | 1 + explorer/src/App.tsx | 16 ++- explorer/src/SearchModal.tsx | 7 +- explorer/src/config.ts | 49 +++++-- indexer/Cargo.toml | 3 + indexer/build.rs | 62 ++++++++ indexer/src/main.rs | 207 +++++++++++++++++++++++++- validator/src/main.rs | 72 ++++++++-- 17 files changed, 728 insertions(+), 172 deletions(-) create mode 100755 deploy.sh create mode 100644 explorer/public/runtime-config.js create mode 100644 indexer/build.rs diff --git a/Cargo.lock b/Cargo.lock index 1bc93a53..ea1b12ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,9 @@ dependencies = [ "rcgen", "reqwest 0.12.28", "rustls", + "serde", + "serde_json", + "serde_yaml", "thiserror", "tokio", "tokio-rustls", diff --git a/chain/src/engine.rs b/chain/src/engine.rs index 85f53fa6..3a99f49d 100644 --- a/chain/src/engine.rs +++ b/chain/src/engine.rs @@ -34,7 +34,7 @@ use commonware_runtime::{ buffer::paged::{page_size, CacheRef}, spawn_cell, BufferPooler, Clock, ContextCell, Handle, Metrics, Spawner, Storage, }; -use commonware_storage::{archive::immutable, queue}; +use commonware_storage::{archive::prunable, queue, translator::FourCap}; use commonware_utils::{ordered::Set, NZUsize, NZU64}; use futures::future::try_join_all; use governor::clock::Clock as GClock; @@ -55,11 +55,7 @@ type Reporter = const SYNCER_ACTIVITY_TIMEOUT_MULTIPLIER: u64 = 10; const PRUNABLE_ITEMS_PER_SECTION: NonZero = NZU64!(4_096); const QUEUE_ITEMS_PER_SECTION: NonZero = NZU64!(128); -const IMMUTABLE_ITEMS_PER_SECTION: NonZero = NZU64!(262_144); -const FREEZER_TABLE_RESIZE_FREQUENCY: u8 = 4; -const FREEZER_TABLE_RESIZE_CHUNK_SIZE: u32 = 2u32.pow(16); // 3MB -const FREEZER_JOURNAL_TARGET_SIZE: u64 = 1024 * 1024 * 1024; // 1GB -const FREEZER_JOURNAL_COMPRESSION: Option = Some(3); +const ARCHIVE_COMPRESSION: Option = Some(3); const REPLAY_BUFFER: NonZero = NZUsize!(8 * 1024 * 1024); // 8MB const WRITE_BUFFER: NonZero = NZUsize!(1024 * 1024); // 1MB const PAGE_CACHE_PHYSICAL_PAGE_SIZE: u32 = 4_096; @@ -133,8 +129,6 @@ pub struct Config< pub blocker: B, pub provider: P, pub partition_prefix: String, - pub blocks_freezer_table_initial_size: u32, - pub finalized_freezer_table_initial_size: u32, pub me: PublicKey, pub polynomial: Sharing, pub share: group::Share, @@ -181,8 +175,8 @@ where E, Standard, ConstantProvider, - immutable::Archive, - immutable::Archive, + prunable::Archive, + prunable::Archive, FixedEpocher, S, >, @@ -234,42 +228,21 @@ where // Create the page cache let page_cache = CacheRef::from_pooler(&context, PAGE_CACHE_PAGE_SIZE, PAGE_CACHE_CAPACITY); - // Initialize finalizations by height + // Validators retain all finalized history. These archives use the prunable write path, + // but Alto never sends Marshal a request to prune them. let start = Instant::now(); - let finalizations_by_height = immutable::Archive::init( + let finalizations_by_height = prunable::Archive::init( context.child("finalizations_by_height"), - immutable::Config { - metadata_partition: format!( - "{}-finalizations-by-height-metadata", - cfg.partition_prefix - ), - freezer_table_partition: format!( - "{}-finalizations-by-height-freezer-table", - cfg.partition_prefix - ), - freezer_table_initial_size: cfg.finalized_freezer_table_initial_size, - freezer_table_resize_frequency: FREEZER_TABLE_RESIZE_FREQUENCY, - freezer_table_resize_chunk_size: FREEZER_TABLE_RESIZE_CHUNK_SIZE, - freezer_key_partition: format!( - "{}-finalizations-by-height-freezer-key-journal", - cfg.partition_prefix - ), - freezer_key_page_cache: page_cache.clone(), - freezer_key_write_buffer: WRITE_BUFFER, - freezer_value_partition: format!( - "{}-finalizations-by-height-freezer-value-journal", - cfg.partition_prefix - ), - freezer_value_write_buffer: WRITE_BUFFER, - freezer_value_target_size: FREEZER_JOURNAL_TARGET_SIZE, - freezer_value_compression: FREEZER_JOURNAL_COMPRESSION, - ordinal_partition: format!( - "{}-finalizations-by-height-ordinal", - cfg.partition_prefix - ), - ordinal_write_buffer: WRITE_BUFFER, - items_per_section: IMMUTABLE_ITEMS_PER_SECTION, + prunable::Config { + translator: FourCap, + key_partition: format!("{}-finalizations-by-height-key", cfg.partition_prefix), + key_page_cache: page_cache.clone(), + value_partition: format!("{}-finalizations-by-height-value", cfg.partition_prefix), + compression: ARCHIVE_COMPRESSION, codec_config: Scheme::certificate_codec_config_unbounded(), + items_per_section: PRUNABLE_ITEMS_PER_SECTION, + key_write_buffer: WRITE_BUFFER, + value_write_buffer: WRITE_BUFFER, replay_buffer: REPLAY_BUFFER, }, ) @@ -279,34 +252,18 @@ where // Initialize finalized blocks let start = Instant::now(); - let finalized_blocks = immutable::Archive::init( + let finalized_blocks = prunable::Archive::init( context.child("finalized_blocks"), - immutable::Config { - metadata_partition: format!("{}-finalized_blocks-metadata", cfg.partition_prefix), - freezer_table_partition: format!( - "{}-finalized_blocks-freezer-table", - cfg.partition_prefix - ), - freezer_table_initial_size: cfg.blocks_freezer_table_initial_size, - freezer_table_resize_frequency: FREEZER_TABLE_RESIZE_FREQUENCY, - freezer_table_resize_chunk_size: FREEZER_TABLE_RESIZE_CHUNK_SIZE, - freezer_key_partition: format!( - "{}-finalized-blocks-freezer-key-journal", - cfg.partition_prefix - ), - freezer_key_page_cache: page_cache.clone(), - freezer_key_write_buffer: WRITE_BUFFER, - freezer_value_partition: format!( - "{}-finalized-blocks-freezer-value-journal", - cfg.partition_prefix - ), - freezer_value_write_buffer: WRITE_BUFFER, - freezer_value_target_size: FREEZER_JOURNAL_TARGET_SIZE, - freezer_value_compression: FREEZER_JOURNAL_COMPRESSION, - ordinal_partition: format!("{}-finalized-blocks-ordinal", cfg.partition_prefix), - ordinal_write_buffer: WRITE_BUFFER, - items_per_section: IMMUTABLE_ITEMS_PER_SECTION, + prunable::Config { + translator: FourCap, + key_partition: format!("{}-finalized-blocks-key", cfg.partition_prefix), + key_page_cache: page_cache.clone(), + value_partition: format!("{}-finalized-blocks-value", cfg.partition_prefix), + compression: ARCHIVE_COMPRESSION, codec_config: (), + items_per_section: PRUNABLE_ITEMS_PER_SECTION, + key_write_buffer: WRITE_BUFFER, + value_write_buffer: WRITE_BUFFER, replay_buffer: REPLAY_BUFFER, }, ) diff --git a/chain/src/lib.rs b/chain/src/lib.rs index ec6da692..be8ae9c9 100644 --- a/chain/src/lib.rs +++ b/chain/src/lib.rs @@ -200,10 +200,6 @@ mod tests { use std::{collections::HashMap, num::NonZeroU32, time::Duration}; use tracing::info; - /// Limit the freezer table size to 1MB because the deterministic runtime stores - /// everything in RAM. - const FREEZER_TABLE_INITIAL_SIZE: u32 = 2u32.pow(14); // 1MB - /// (Effectively) unlimited quota for tests. const TEST_QUOTA: Quota = Quota::per_second(NZU32!(u32::MAX)); @@ -421,8 +417,6 @@ mod tests { blocker: oracle.control(public_key.clone()), provider: oracle.manager(), partition_prefix: uid.clone(), - blocks_freezer_table_initial_size: FREEZER_TABLE_INITIAL_SIZE, - finalized_freezer_table_initial_size: FREEZER_TABLE_INITIAL_SIZE, me: signer.public_key(), polynomial: scheme.polynomial().clone(), share: scheme.share().cloned().unwrap(), diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 00000000..152c8418 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Generate, build, and deploy the global Alto cluster. +set -euo pipefail +cd "$(dirname "$0")" + +for tool in cargo just docker deployer npm; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "missing required command: $tool" >&2 + exit 1 + fi +done +if ! docker info >/dev/null 2>&1; then + echo "docker daemon is unavailable" >&2 + exit 1 +fi +if ! docker buildx version >/dev/null 2>&1; then + echo "docker buildx is unavailable" >&2 + exit 1 +fi +if [ ! -f deploy/dashboard.json ]; then + echo "missing deploy/dashboard.json" >&2 + exit 1 +fi + +if [ -d assets ]; then + read -r -p "./assets exists — remove and regenerate? [y/N] " answer + [ "$answer" = "y" ] || exit 1 + rm -rf ./assets +fi + +# Keep enough of the 16-vCPU machine free for networking, storage, and consensus +# while reserving most of the remaining compute for signature verification. +cargo run --locked --bin deploy -- generate \ + --peers 50 \ + --bootstrappers 5 \ + --worker-threads 4 \ + --log-level info \ + --mailbox-size 16384 \ + --deque-size 256 \ + --signature-threads 12 \ + --leader-mode stable \ + --leader-delay-ms 5 \ + --leader-term-length 100000 \ + --output assets \ + remote \ + --regions us-west-1,us-east-1,eu-west-1,ap-northeast-1,eu-north-1,ap-south-1,sa-east-1,eu-central-1,ap-northeast-2,ap-southeast-2 \ + --monitoring-instance-type c8g.4xlarge \ + --monitoring-storage-size 100 \ + --instance-type i7i.4xlarge \ + --storage-size 25 \ + --dashboard deploy/dashboard.json \ + --indexer + +for artifact in config.yaml dashboard.json indexer.yaml; do + if [ ! -f "assets/$artifact" ]; then + echo "deployment generator did not create assets/$artifact" >&2 + exit 1 + fi +done + +npm --prefix explorer ci +GENERATE_SOURCEMAP=false npm --prefix explorer run build:react +if [ ! -f explorer/build/index.html ]; then + echo "explorer build did not create explorer/build/index.html" >&2 + exit 1 +fi + +just intel-binaries +for artifact in validator indexer; do + if [ ! -x "assets/$artifact" ]; then + echo "build did not create executable assets/$artifact" >&2 + exit 1 + fi +done + +(cd assets && deployer aws create --config config.yaml --concurrency 50) + +DEPLOY_TAG="$(sed -n 's/^tag: //p' assets/config.yaml)" +HOSTS_PATH="${HOME}/.commonware_deployer/${DEPLOY_TAG}/hosts.yaml" +if [ -f "$HOSTS_PATH" ]; then + INDEXER_IP="$(awk ' + $1 == "-" && $2 == "name:" { current = $3; next } + $1 == "name:" { current = $2; next } + current == "indexer" && $1 == "ip:" { print $2; exit } + ' "$HOSTS_PATH")" + if [ -n "$INDEXER_IP" ]; then + echo "Explorer: http://${INDEXER_IP}:8080/" + else + echo "Explorer is on port 8080; indexer IP is in $HOSTS_PATH" + fi +fi diff --git a/deploy/Dockerfile b/deploy/Dockerfile index c1de1523..f3dbdb76 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -42,6 +42,9 @@ ENV PATH="/root/.cargo/bin:${PATH}" \ TARGET_CXX="${TARGET_CXX}" \ TARGET_AR="${TARGET_AR}" \ TARGET_STRIP="${TARGET_STRIP}" \ + PACKAGE_NAME="alto-validator" \ + BINARY_NAME="validator" \ + OUTPUT_NAME="validator" \ CARGO_PROFILE_RELEASE_DEBUG=true RUN rustup target add ${TARGET_TRIPLE} @@ -49,4 +52,4 @@ RUN rustup target add ${TARGET_TRIPLE} WORKDIR /alto ENTRYPOINT ["/bin/bash", "-c"] -CMD ["set -euo pipefail && export CARGO_TARGET_${TARGET_CARGO_NAME}_LINKER=${TARGET_LINKER} && export CARGO_TARGET_${TARGET_CARGO_NAME}_RUSTFLAGS=\"-C target-cpu=${TARGET_CPU}\" && export CC_${TARGET_CC_NAME}=${TARGET_LINKER} && export CXX_${TARGET_CC_NAME}=${TARGET_CXX} && export AR_${TARGET_CC_NAME}=${TARGET_AR} && mkdir -p assets && cargo build --locked --release --package alto-validator --bin validator --target ${TARGET_TRIPLE} && cp target/${TARGET_TRIPLE}/release/validator assets/validator-debug && ${TARGET_STRIP} -o assets/validator assets/validator-debug"] +CMD ["set -euo pipefail && export CARGO_TARGET_${TARGET_CARGO_NAME}_LINKER=${TARGET_LINKER} && export CARGO_TARGET_${TARGET_CARGO_NAME}_RUSTFLAGS=\"-C target-cpu=${TARGET_CPU}\" && export CC_${TARGET_CC_NAME}=${TARGET_LINKER} && export CXX_${TARGET_CC_NAME}=${TARGET_CXX} && export AR_${TARGET_CC_NAME}=${TARGET_AR} && mkdir -p assets && cargo build --locked --release --package ${PACKAGE_NAME} --bin ${BINARY_NAME} --target ${TARGET_TRIPLE} && cp target/${TARGET_TRIPLE}/release/${BINARY_NAME} assets/${OUTPUT_NAME}-debug && ${TARGET_STRIP} -o assets/${OUTPUT_NAME} assets/${OUTPUT_NAME}-debug"] diff --git a/deploy/README.md b/deploy/README.md index 3ffba8a9..152dd4cd 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -94,7 +94,7 @@ _MacOS defaults to 256 open files, which is too low for the default settings (wh ### Remote -_To run a deploy, you must first install [Rust](https://www.rust-lang.org/tools/install), [Docker with Buildx](https://docs.docker.com/build/buildx/install/), and [just](https://just.systems/man/en/packages.html)._ +_To run a deploy, you must first install [Rust](https://www.rust-lang.org/tools/install), [Node.js with npm](https://nodejs.org/), [Docker with Buildx](https://docs.docker.com/build/buildx/install/), and [just](https://just.systems/man/en/packages.html)._ #### Install `commonware-deployer` @@ -104,7 +104,12 @@ cargo install commonware-deployer --features aws #### Create Artifacts -_To configure remote indexer upload, add `--indexers ':[;:...]'` to the `generate remote` command. For example, `https://idx-a.example.com:2;https://idx-b.example.com:1`. Indexers are selected in round-robin fashion across regions._ +Pass `--indexer` to deploy Alto's indexer alongside the validators. The generator configures one +validator in each region to upload to `http://indexer:8080`. The indexer also serves the explorer +from port 8080, so its public URL can be opened directly from a laptop. To use indexers managed +outside this deployment instead, pass `--indexers ':[;:...]'`; for example, +`https://idx-a.example.com:2;https://idx-b.example.com:1`. Uploaders are selected round-robin +across regions. Each selected validator config will contain: @@ -115,9 +120,14 @@ indexer: https://your-indexer.example.com ##### Global ```bash -cargo run --bin deploy -- generate --peers 50 --bootstrappers 5 --worker-threads 2 --log-level info --mailbox-size 16384 --deque-size 256 --signature-threads 2 --leader-mode stable --leader-delay-ms 10 --leader-term-length 1000 --output assets remote --regions us-west-1,us-east-1,eu-west-1,ap-northeast-1,eu-north-1,ap-south-1,sa-east-1,eu-central-1,ap-northeast-2,ap-southeast-2 --monitoring-instance-type c8g.4xlarge --monitoring-storage-size 100 --instance-type c8g.large --storage-size 25 --dashboard deploy/dashboard.json +./deploy.sh ``` +This deploys 50 validators on `i7i.4xlarge` NVMe instances with 4 Tokio workers and 12 signature +threads, a 5ms proposal target, and 100,000-view stable-leader terms. It builds the explorer into an +indexer on the same instance type, deploys with concurrency 50, and prints the explorer URL when +deployment completes. Validators use prunable finalized archives but retain the full history. + _This configuration consumes ~10MB of disk space per hour per validator (~5 views per second). With 25GB of storage allocated, validators will exhaust available storage in ~3 months._ ##### USA @@ -151,36 +161,38 @@ NEW_KEY="" sed -i '' "s/$OLD_KEY/$NEW_KEY/g" follower/examples/usa.yml ``` -#### Build Validator Binary +#### Build Deployment Binaries The build platform is an explicit recipe: | Recipe | Output architecture | CPU target | Example instances | | --- | --- | --- | --- | | `just validator-graviton-binary` | ARM64 | `neoverse-v1` | Graviton 3/4 (`c7g`, `c8g`) | +| `just indexer-graviton-binary` | ARM64 | `neoverse-v1` | Graviton 3/4 (`c7g`, `c8g`) | | `just validator-intel-binary` | x86-64 | `emeraldrapids` | Intel I7i | +| `just indexer-intel-binary` | x86-64 | `emeraldrapids` | Intel I7i | ##### Intel I7i ```bash -just validator-intel-binary +just intel-binaries ``` _The Intel binary is compiled with `target-cpu=emeraldrapids`._ -The builder runs on the local Docker architecture and cross-compiles the validator, so no +The builder runs on the local Docker architecture and cross-compiles the binaries, so no `--platform` argument is needed on an ARM64 development machine. ##### Graviton ```bash -just validator-graviton-binary +just graviton-binaries ``` _The Graviton binary is compiled with `target-cpu=neoverse-v1`._ -Both recipes write `assets/validator` and `assets/validator-debug`. Run the recipe matching the -deployment's instance type last. +The grouped recipes write `assets/validator`, `assets/indexer`, and their debug-symbol variants. +Run the recipe matching the deployment's instance type last. ###### Local Compilation @@ -191,9 +203,9 @@ just build-intel-image docker run --rm -v "${PWD}:/alto" -v "${PWD}/../monorepo:/monorepo" alto-validator-builder:intel-local ``` -_Emitted binary `validator` is placed in `assets/`._ +Emitted binaries are placed in `assets/`. -#### Deploy Validator Binary +#### Deploy Cluster ```bash cd assets diff --git a/deploy/justfile b/deploy/justfile index f5d350a5..beac7a92 100644 --- a/deploy/justfile +++ b/deploy/justfile @@ -8,10 +8,20 @@ build-graviton-image: docker buildx bake --progress plain -f deploy/docker-bake.hcl graviton validator-graviton-binary: build-graviton-image prepare-docker-cache - docker run --rm {{source-mounts}} {{cache-env}} alto-validator-builder:graviton-local + docker run --rm {{source-mounts}} {{cache-env}} -e PACKAGE_NAME=alto-validator -e BINARY_NAME=validator -e OUTPUT_NAME=validator alto-validator-builder:graviton-local + +indexer-graviton-binary: build-graviton-image prepare-docker-cache + docker run --rm {{source-mounts}} {{cache-env}} -e PACKAGE_NAME=alto-indexer -e BINARY_NAME=indexer -e OUTPUT_NAME=indexer alto-validator-builder:graviton-local + +graviton-binaries: validator-graviton-binary indexer-graviton-binary build-intel-image: docker buildx bake --progress plain -f deploy/docker-bake.hcl intel validator-intel-binary: build-intel-image prepare-docker-cache - docker run --rm {{source-mounts}} {{cache-env}} alto-validator-builder:intel-local + docker run --rm {{source-mounts}} {{cache-env}} -e PACKAGE_NAME=alto-validator -e BINARY_NAME=validator -e OUTPUT_NAME=validator alto-validator-builder:intel-local + +indexer-intel-binary: build-intel-image prepare-docker-cache + docker run --rm {{source-mounts}} {{cache-env}} -e PACKAGE_NAME=alto-indexer -e BINARY_NAME=indexer -e OUTPUT_NAME=indexer alto-validator-builder:intel-local + +intel-binaries: validator-intel-binary indexer-intel-binary diff --git a/deploy/src/main.rs b/deploy/src/main.rs index 0cc45cc1..b22e259f 100644 --- a/deploy/src/main.rs +++ b/deploy/src/main.rs @@ -4,7 +4,7 @@ use alto_chain::{ DEFAULT_STORAGE_BUFFER_POOL_MAX_PER_CLASS, }; use alto_types::NAMESPACE; -use clap::{value_parser, Arg, ArgMatches, Command}; +use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; use commonware_codec::{Decode, DecodeExt, Encode}; use commonware_consensus::simplex::scheme::bls12381_threshold::vrf as bls12381_threshold; use commonware_cryptography::{ @@ -22,7 +22,7 @@ use commonware_math::algebra::Random; use commonware_utils::{sys_rng, NZU32}; use rand::seq::IteratorRandom; use std::{ - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, BTreeSet, HashMap, VecDeque}, fs, net::{IpAddr, Ipv4Addr, SocketAddr}, num::{NonZeroU32, NonZeroU64, NonZeroUsize}, @@ -34,6 +34,10 @@ use uuid::Uuid; static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; const BINARY_NAME: &str = "validator"; +const INDEXER_BINARY_NAME: &str = "indexer"; +const INDEXER_HOST: &str = "indexer"; +const INDEXER_CONFIG_FILE: &str = "indexer.yaml"; +const INDEXER_PORT: u16 = 8080; const PORT: u16 = 4545; const STORAGE_CLASS: &str = "gp3"; const DASHBOARD_FILE: &str = "dashboard.json"; @@ -68,6 +72,20 @@ struct ConfiguredIndexer { count: usize, } +#[derive(serde::Serialize)] +struct DeployedIndexerConfig { + port: u16, + identity: String, + explorer: DeployedExplorerConfig, +} + +#[derive(serde::Serialize)] +struct DeployedExplorerConfig { + name: String, + description: String, + locations: Vec<([f64; 2], String)>, +} + fn local_indexer_port(url: &str) -> Option { let rest = url .strip_prefix("http://localhost:") @@ -116,6 +134,44 @@ fn parse_indexers(specs: Option<&String>) -> Vec { .collect() } +fn select_regional_peers( + regions: &[String], + count: usize, +) -> (Vec, BTreeMap) { + assert!( + count <= regions.len(), + "indexer count exceeds number of peers" + ); + + let mut region_to_peers: BTreeMap> = BTreeMap::new(); + for (index, region) in regions.iter().enumerate() { + region_to_peers + .entry(region.clone()) + .or_default() + .push_back(index); + } + + let mut selected = Vec::with_capacity(count); + let mut assigned = BTreeMap::new(); + while selected.len() < count { + let mut progressed = false; + for (region, peers) in &mut region_to_peers { + let Some(peer) = peers.pop_front() else { + continue; + }; + selected.push(peer); + *assigned.entry(region.clone()).or_insert(0) += 1; + progressed = true; + if selected.len() == count { + break; + } + } + assert!(progressed, "indexer count exceeds number of peers"); + } + + (selected, assigned) +} + fn parse_leader(matches: &ArgMatches) -> Result { let delay_ms = *matches.get_one::("leader_delay_ms").unwrap(); match matches.get_one::("leader_mode").unwrap().as_str() { @@ -277,6 +333,15 @@ fn main() { .required(true) .value_parser(value_parser!(String)), ) + .arg( + Arg::new("indexer") + .long("indexer") + .action(ArgAction::SetTrue) + .conflicts_with("indexers") + .help( + "Deploy an indexer and configure one validator per region to upload to it", + ), + ) .arg( Arg::new("indexers") .long("indexers") @@ -631,7 +696,15 @@ fn generate_remote( .get_one::("monitoring_storage_size") .unwrap(); let dashboard = sub_matches.get_one::("dashboard").unwrap().clone(); - let configured_indexers = parse_indexers(sub_matches.get_one::("indexers")); + let deploy_indexer = sub_matches.get_flag("indexer"); + let mut configured_indexers = parse_indexers(sub_matches.get_one::("indexers")); + if deploy_indexer { + let region_count = regions.iter().collect::>().len(); + configured_indexers.push(ConfiguredIndexer { + url: format!("http://{INDEXER_HOST}:{INDEXER_PORT}"), + count: region_count, + }); + } // Construct output path let raw_current_dir = std::env::current_dir().unwrap(); @@ -745,46 +818,12 @@ fn generate_remote( .iter() .map(|indexer| indexer.count) .sum(); - assert!( - total_indexer_count <= peer_configs.len(), - "indexer count exceeds number of peers" - ); - - // Group peers by region - let mut region_to_peers: BTreeMap> = BTreeMap::new(); - for (idx, instance) in instance_configs.iter().enumerate() { - region_to_peers - .entry(instance.region.clone()) - .or_default() - .push(idx); - } - - // Sort peers within each region for deterministic selection - for peers in region_to_peers.values_mut() { - peers.sort(); - } - - // Get sorted list of regions for consistent iteration - let region_list: Vec = region_to_peers.keys().cloned().collect(); - - // Select peers for indexers in a round-robin fashion across regions - let mut selected_indices = Vec::new(); - let mut region_index = 0; - let mut assigned_regions: BTreeMap<&String, usize> = BTreeMap::new(); - while selected_indices.len() < total_indexer_count && !region_to_peers.is_empty() { - let region = ®ion_list[region_index % region_list.len()]; - if let Some(peers) = region_to_peers.get_mut(region) { - if !peers.is_empty() { - let peer_idx = peers.remove(0); - selected_indices.push(peer_idx); - if peers.is_empty() { - region_to_peers.remove(region); - } - *assigned_regions.entry(region).or_insert(0) += 1; - } - } - region_index += 1; - } + let peer_regions = instance_configs + .iter() + .map(|instance| instance.region.clone()) + .collect::>(); + let (selected_indices, assigned_regions) = + select_regional_peers(&peer_regions, total_indexer_count); // Update selected peer configs let indexer_assignments = configured_indexers @@ -797,7 +836,65 @@ fn generate_remote( info!(assignments = ?assigned_regions, "configured indexers"); } + let deployed_indexer_config = deploy_indexer.then(|| { + let locations = instance_configs + .iter() + .map(|instance| { + get_aws_location(&instance.region) + .unwrap_or_else(|| panic!("unknown AWS region: {}", instance.region)) + }) + .collect(); + let unique_regions = regions.iter().fold(Vec::new(), |mut unique, region| { + if !unique.contains(region) { + unique.push(region.clone()); + } + unique + }); + + DeployedIndexerConfig { + port: INDEXER_PORT, + identity: hex(&identity.encode()), + explorer: DeployedExplorerConfig { + name: "Live Global Cluster".to_string(), + description: format!( + "A live cluster of {peers} validators running {instance_type} nodes on AWS in {} regions ({}).", + unique_regions.len(), + unique_regions.join(", ") + ), + locations, + }, + } + }); + + if deploy_indexer { + instance_configs.push(aws::InstanceConfig { + name: INDEXER_HOST.to_string(), + region: regions[0].clone(), + availability_zone_group: None, + instance_type: instance_type.clone(), + storage_size, + storage_class: STORAGE_CLASS.to_string(), + storage_iops: None, + storage_throughput: None, + binary: INDEXER_BINARY_NAME.to_string(), + config: INDEXER_CONFIG_FILE.to_string(), + profiling: false, + }); + } + // Generate root config file + let mut ports = vec![aws::PortConfig { + protocol: "tcp".to_string(), + port: PORT, + cidr: "0.0.0.0/0".to_string(), + }]; + if deploy_indexer { + ports.push(aws::PortConfig { + protocol: "tcp".to_string(), + port: INDEXER_PORT, + cidr: "0.0.0.0/0".to_string(), + }); + } let config = aws::Config { tag, instances: instance_configs, @@ -809,11 +906,7 @@ fn generate_remote( storage_throughput: None, dashboard: DASHBOARD_FILE.to_string(), }, - ports: vec![aws::PortConfig { - protocol: "tcp".to_string(), - port: PORT, - cidr: "0.0.0.0/0".to_string(), - }], + ports, }; // Write configuration files @@ -823,6 +916,15 @@ fn generate_remote( format!("{output}/{DASHBOARD_FILE}"), ) .unwrap(); + if let Some(indexer_config) = deployed_indexer_config { + let path = format!("{output}/{INDEXER_CONFIG_FILE}"); + let file = fs::File::create(&path).unwrap(); + serde_yaml::to_writer(file, &indexer_config).unwrap(); + info!( + path = INDEXER_CONFIG_FILE, + "wrote indexer configuration file" + ); + } for (peer_config_file, peer_config) in peer_configs { let path = format!("{output}/{peer_config_file}"); let file = fs::File::create(&path).unwrap(); @@ -898,8 +1000,13 @@ fn explorer_remote(dir: String, backend_url: String) { let config_content = fs::read_to_string(&config_path).expect("failed to read config.yaml"); let config: aws::Config = serde_yaml::from_str(&config_content).expect("failed to parse config.yaml"); + let validator_instances = config + .instances + .iter() + .filter(|instance| instance.binary == BINARY_NAME) + .collect::>(); let mut participants = BTreeMap::new(); - for instance in &config.instances { + for instance in &validator_instances { let region = &instance.region; let public_key = from_hex(&instance.name).expect("invalid public key"); let public_key = PublicKey::decode(public_key.as_ref()).expect("invalid public key"); @@ -918,7 +1025,7 @@ fn explorer_remote(dir: String, backend_url: String) { // Generate config.ts let locations_str = locations.join(",\n"); - let first_instance = &config.instances[0]; + let first_instance = validator_instances.first().expect("no validators found"); let peer_config_path = format!("{}/{}", dir, first_instance.config); let peer_config_content = fs::read_to_string(&peer_config_path).expect("failed to read peer config"); @@ -949,7 +1056,10 @@ fn explorer_remote(dir: String, backend_url: String) { #[cfg(test)] mod tests { - use super::{block_size_arg, leader_args, parse_indexers, parse_leader, ConfiguredIndexer}; + use super::{ + block_size_arg, leader_args, parse_indexers, parse_leader, select_regional_peers, + ConfiguredIndexer, + }; use alto_chain::Leader; use clap::Command; use commonware_utils::{NZU32, NZU64}; @@ -1124,4 +1234,24 @@ signature_threads: 1 fn parse_indexers_allows_empty_configuration() { assert!(parse_indexers(None).is_empty()); } + + #[test] + fn regional_selection_uses_each_region_before_repeating() { + let regions = [ + "us-west-1", + "us-east-1", + "eu-west-1", + "us-west-1", + "us-east-1", + "eu-west-1", + ] + .map(str::to_string); + + let (selected, assigned) = select_regional_peers(®ions, 3); + assert_eq!(selected, vec![2, 1, 0]); + assert_eq!( + assigned.values().copied().collect::>(), + vec![1, 1, 1] + ); + } } diff --git a/explorer/public/index.html b/explorer/public/index.html index 3db31351..441e8075 100644 --- a/explorer/public/index.html +++ b/explorer/public/index.html @@ -37,7 +37,8 @@ +
- \ No newline at end of file + diff --git a/explorer/public/runtime-config.js b/explorer/public/runtime-config.js new file mode 100644 index 00000000..be368b79 --- /dev/null +++ b/explorer/public/runtime-config.js @@ -0,0 +1 @@ +// A deployed indexer serves live cluster configuration at this path. diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index fa5d383b..fd8b9008 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -3,7 +3,15 @@ import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet"; import { DivIcon, LatLng } from "leaflet"; import "leaflet/dist/leaflet.css"; import init, { parse_seed, parse_notarized, parse_finalized, leader_index } from "./alto_types/alto_types.js"; -import { getClusterConfig, getClusters, Cluster, DEFAULT_CLUSTER, MODE } from "./config"; +import { + getClusterConfig, + getClusters, + getHttpBackendUrl, + getWebSocketBackendUrl, + Cluster, + DEFAULT_CLUSTER, + MODE, +} from "./config"; import { SeedJs, NotarizedJs, FinalizedJs, ViewData } from "./types"; import { hexToUint8Array, hexUint8Array } from "./utils"; import "./App.css"; @@ -169,8 +177,7 @@ const App: React.FC = () => { // Health check function const checkHealth = useCallback(async () => { try { - const protocol = MODE === 'local' ? 'http' : 'https'; - const response = await fetch(`${protocol}://${BACKEND_URL}/health`, { + const response = await fetch(`${getHttpBackendUrl(BACKEND_URL)}/health`, { method: "GET", headers: { "Cache-Control": "no-cache, no-store, must-revalidate", @@ -634,8 +641,7 @@ const App: React.FC = () => { // Create new WebSocket connection const wsCreationTime = Date.now(); - const protocol = MODE === 'local' ? 'ws' : 'wss'; - const ws = new WebSocket(`${protocol}://${BACKEND_URL}/consensus/ws`); + const ws = new WebSocket(`${getWebSocketBackendUrl(BACKEND_URL)}/consensus/ws`); wsRef.current = ws; ws.binaryType = "arraybuffer"; diff --git a/explorer/src/SearchModal.tsx b/explorer/src/SearchModal.tsx index 5a55b4e2..e7064437 100644 --- a/explorer/src/SearchModal.tsx +++ b/explorer/src/SearchModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import './SearchModal.css'; -import { ClusterConfig, MODE } from './config'; +import { ClusterConfig, getHttpBackendUrl } from './config'; import { FinalizedJs, NotarizedJs, BlockJs, SearchType, SearchResult } from './types'; import { hexToUint8Array, hexUint8Array, formatAge } from './utils'; import init, { parse_seed, parse_notarized, parse_finalized, parse_block } from "./alto_types/alto_types.js"; @@ -141,8 +141,7 @@ const SearchModal: React.FC = ({ isOpen, onClose, clusterConfi } const { BACKEND_URL, PUBLIC_KEY_HEX } = clusterConfig; - const protocol = MODE === 'local' ? 'http' : 'https'; - const baseUrl = `${protocol}://${BACKEND_URL}`; + const baseUrl = getHttpBackendUrl(BACKEND_URL); const PUBLIC_KEY = hexToUint8Array(PUBLIC_KEY_HEX); let endpoint = ''; @@ -398,4 +397,4 @@ const SearchModal: React.FC = ({ isOpen, onClose, clusterConfi ); }; -export default SearchModal; \ No newline at end of file +export default SearchModal; diff --git a/explorer/src/config.ts b/explorer/src/config.ts index 2eb413ca..a57d76fa 100644 --- a/explorer/src/config.ts +++ b/explorer/src/config.ts @@ -13,13 +13,25 @@ export interface ClusterConfig { description: string; } +interface DeployedClusterConfig extends ClusterConfig { + mode: Mode; +} + +declare global { + interface Window { + ALTO_DEPLOYMENT?: DeployedClusterConfig; + } +} + +const deployedConfig = window.ALTO_DEPLOYMENT; + // Detect mode from environment variable (set at build time) // Usage: REACT_APP_MODE=local npm start // Default to 'public' if not set -export const MODE: Mode = (process.env.REACT_APP_MODE as Mode) || 'public'; +export const MODE: Mode = deployedConfig?.mode || (process.env.REACT_APP_MODE as Mode) || 'public'; // Build configs based on mode -const publicConfigs: Record<'global' | 'usa', ClusterConfig> = { +const defaultPublicConfigs: Record<'global' | 'usa', ClusterConfig> = { global: { ...globalConfig, name: 'Global Cluster', @@ -32,11 +44,17 @@ const publicConfigs: Record<'global' | 'usa', ClusterConfig> = { }, }; -const localClusterConfig: ClusterConfig = { - ...localConfig, - name: 'Local Cluster', - description: `A local test cluster running on localhost.`, -}; +const publicConfigs: Partial> = deployedConfig + ? { global: deployedConfig } + : defaultPublicConfigs; + +const localClusterConfig: ClusterConfig = deployedConfig && MODE === 'local' + ? deployedConfig + : { + ...localConfig, + name: 'Local Cluster', + description: `A local test cluster running on localhost.`, + }; export const DEFAULT_CLUSTER: Cluster = MODE === 'public' ? 'global' : 'local'; @@ -47,7 +65,7 @@ export const getClusterConfig = (cluster: Cluster): ClusterConfig => { if (cluster === 'local') { return localClusterConfig; } - return publicConfigs[cluster]; + return publicConfigs[cluster] || publicConfigs.global!; }; export const getClusters = (): Record => { @@ -56,3 +74,18 @@ export const getClusters = (): Record => { } return publicConfigs as Record; }; + +export const getHttpBackendUrl = (backendUrl: string): string => { + if (backendUrl === window.location.host) { + return window.location.origin; + } + return `${MODE === 'local' ? 'http' : 'https'}://${backendUrl}`; +}; + +export const getWebSocketBackendUrl = (backendUrl: string): string => { + if (backendUrl === window.location.host) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}`; + } + return `${MODE === 'local' ? 'ws' : 'wss'}://${backendUrl}`; +}; diff --git a/indexer/Cargo.toml b/indexer/Cargo.toml index f2780b49..bf7a34b9 100644 --- a/indexer/Cargo.toml +++ b/indexer/Cargo.toml @@ -27,6 +27,9 @@ thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } clap = { workspace = true, features = ["derive"] } +serde = { version = "1.0.218", features = ["derive"] } +serde_json = "1.0.150" +serde_yaml = "0.9.34" tokio = { workspace = true, features = ["full"] } axum = { workspace = true, features = ["ws"] } tower = { workspace = true } diff --git a/indexer/build.rs b/indexer/build.rs new file mode 100644 index 00000000..cdea66c3 --- /dev/null +++ b/indexer/build.rs @@ -0,0 +1,62 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +fn collect_files(directory: &Path, files: &mut Vec) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries { + let path = entry.expect("failed to read explorer build entry").path(); + if path.is_dir() { + collect_files(&path, files); + } else { + files.push(path); + } + } +} + +fn content_type(path: &Path) -> &'static str { + match path.extension().and_then(|extension| extension.to_str()) { + Some("css") => "text/css; charset=utf-8", + Some("html") => "text/html; charset=utf-8", + Some("ico") => "image/x-icon", + Some("js") => "application/javascript; charset=utf-8", + Some("json") | Some("map") => "application/json; charset=utf-8", + Some("png") => "image/png", + Some("svg") => "image/svg+xml", + Some("txt") => "text/plain; charset=utf-8", + Some("wasm") => "application/wasm", + _ => "application/octet-stream", + } +} + +fn main() { + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let explorer_build = manifest_dir.join("../explorer/build"); + println!("cargo:rerun-if-changed={}", explorer_build.display()); + + let mut files = Vec::new(); + collect_files(&explorer_build, &mut files); + files.sort(); + + let mut generated = + String::from("pub(crate) static EXPLORER_ASSETS: &[(&str, &[u8], &str)] = &[\n"); + for path in files { + let route = path + .strip_prefix(&explorer_build) + .expect("explorer asset is outside build directory") + .to_string_lossy() + .replace('\\', "/"); + generated.push_str(&format!( + " ({route:?}, include_bytes!({path:?}), {content_type:?}),\n", + path = path.to_string_lossy(), + content_type = content_type(&path), + )); + } + generated.push_str("];\n"); + + let output = PathBuf::from(env::var_os("OUT_DIR").unwrap()).join("explorer_assets.rs"); + fs::write(output, generated).expect("failed to generate embedded explorer assets"); +} diff --git a/indexer/src/main.rs b/indexer/src/main.rs index 5c20b360..4990545c 100644 --- a/indexer/src/main.rs +++ b/indexer/src/main.rs @@ -1,12 +1,22 @@ use alto_indexer::{Api, Indexer}; use alto_types::{Identity, Scheme, NAMESPACE}; +use axum::{ + body::{Body, Bytes}, + extract::Extension, + http::{header, HeaderValue, StatusCode, Uri}, + response::{IntoResponse, Response}, + routing::get, +}; use clap::Parser; use commonware_codec::DecodeExt; use commonware_formatting::from_hex; use commonware_parallel::Sequential; -use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, sync::Arc}; use tracing::info; +include!(concat!(env!("OUT_DIR"), "/explorer_assets.rs")); + #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -18,15 +28,152 @@ struct Args { #[clap( long, + required_unless_present = "config", + conflicts_with = "config", help = "Identity public key in hex format (BLS12-381 public key)" )] + identity: Option, + + /// Path to the deployer-generated hosts file. + #[clap(long, requires = "config", conflicts_with = "identity")] + hosts: Option, + + /// Path to the deployer-provided indexer config YAML. + #[clap(long, requires = "hosts", conflicts_with = "identity")] + config: Option, +} + +#[derive(Deserialize)] +struct DeployerConfig { + port: u16, identity: String, + explorer: Option, +} + +#[derive(Deserialize)] +struct ExplorerConfig { + name: String, + description: String, + locations: Vec<([f64; 2], String)>, +} + +struct Settings { + port: u16, + identity: String, + explorer: ExplorerConfig, + explorer_mode: &'static str, +} + +#[derive(Serialize)] +struct RuntimeExplorerConfig<'a> { + #[serde(rename = "PUBLIC_KEY_HEX")] + public_key_hex: &'a str, + #[serde(rename = "LOCATIONS")] + locations: &'a [([f64; 2], String)], + name: &'a str, + description: &'a str, + mode: &'a str, +} + +#[derive(Clone)] +struct ExplorerScript(String); + +fn local_explorer_config() -> ExplorerConfig { + ExplorerConfig { + name: "Local Indexer".to_string(), + description: "An Alto indexer running on this machine.".to_string(), + locations: Vec::new(), + } +} + +fn load_settings(args: Args) -> Result> { + if let Some(config) = args.config { + debug_assert!(args.hosts.is_some()); + let config = std::fs::read_to_string(config)?; + let config: DeployerConfig = serde_yaml::from_str(&config)?; + let explorer_mode = if config.explorer.is_some() { + "public" + } else { + "local" + }; + return Ok(Settings { + port: config.port, + identity: config.identity, + explorer: config.explorer.unwrap_or_else(local_explorer_config), + explorer_mode, + }); + } + + Ok(Settings { + port: args.port, + identity: args + .identity + .expect("clap requires --identity when --config is absent"), + explorer: local_explorer_config(), + explorer_mode: "local", + }) +} + +fn explorer_script(settings: &Settings) -> Result { + let config = RuntimeExplorerConfig { + public_key_hex: &settings.identity, + locations: &settings.explorer.locations, + name: &settings.explorer.name, + description: &settings.explorer.description, + mode: settings.explorer_mode, + }; + Ok(format!( + "window.ALTO_DEPLOYMENT = {};\nwindow.ALTO_DEPLOYMENT.BACKEND_URL = window.location.host;\n", + serde_json::to_string(&config)? + )) +} + +fn response(body: Body, content_type: &'static str) -> Response { + let mut response = Response::new(body); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, no-store, must-revalidate"), + ); + response +} + +async fn runtime_explorer_config(Extension(script): Extension) -> Response { + response( + Body::from(script.0), + "application/javascript; charset=utf-8", + ) +} + +async fn explorer_asset(uri: Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + let path = if path.is_empty() { "index.html" } else { path }; + let asset = EXPLORER_ASSETS + .iter() + .find(|(asset_path, _, _)| *asset_path == path) + .or_else(|| { + (!path.contains('.')) + .then(|| { + EXPLORER_ASSETS + .iter() + .find(|(asset_path, _, _)| *asset_path == "index.html") + }) + .flatten() + }); + let Some((_, contents, content_type)) = asset else { + return StatusCode::NOT_FOUND.into_response(); + }; + + response(Body::from(Bytes::from_static(contents)), content_type) } #[tokio::main] async fn main() -> Result<(), Box> { // Parse args - let args = Args::parse(); + let settings = load_settings(Args::parse())?; + let explorer_script = ExplorerScript(explorer_script(&settings)?); // Create logger tracing_subscriber::fmt() @@ -34,20 +181,68 @@ async fn main() -> Result<(), Box> { .init(); // Parse identity - let bytes = from_hex(&args.identity).ok_or("Invalid identity hex format")?; + let bytes = from_hex(&settings.identity).ok_or("Invalid identity hex format")?; let identity: Identity = Identity::decode(&mut bytes.as_slice()).map_err(|_| "Failed to decode identity")?; // Initialize indexer let certificate_verifier = Scheme::certificate_verifier(NAMESPACE, identity); let indexer = Arc::new(Indexer::new(certificate_verifier, Sequential)); - let app = Api::new(indexer).router(); + let app = Api::new(indexer) + .router() + .route("/runtime-config.js", get(runtime_explorer_config)) + .fallback(explorer_asset) + .layer(Extension(explorer_script)); // Start server - let addr = format!("0.0.0.0:{}", args.port); + let addr = format!("0.0.0.0:{}", settings.port); let listener = tokio::net::TcpListener::bind(&addr).await?; - info!(?identity, ?addr, "started indexer"); + info!( + ?identity, + ?addr, + explorer = !EXPLORER_ASSETS.is_empty(), + "started indexer" + ); axum::serve(listener, app).await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{explorer_script, Args, ExplorerConfig, Settings}; + use clap::Parser; + + #[test] + fn accepts_direct_and_deployer_modes() { + assert!(Args::try_parse_from(["indexer", "--identity", "abcd"]).is_ok()); + assert!(Args::try_parse_from([ + "indexer", + "--hosts", + "hosts.yaml", + "--config", + "config.yaml", + ]) + .is_ok()); + assert!(Args::try_parse_from(["indexer"]).is_err()); + } + + #[test] + fn runtime_config_uses_deployed_identity_and_locations() { + let settings = Settings { + port: 8080, + identity: "abcd".to_string(), + explorer: ExplorerConfig { + name: "Live Cluster".to_string(), + description: "description".to_string(), + locations: vec![([1.0, 2.0], "City".to_string())], + }, + explorer_mode: "public", + }; + + let script = explorer_script(&settings).unwrap(); + assert!(script.contains(r#""PUBLIC_KEY_HEX":"abcd""#)); + assert!(script.contains(r#""LOCATIONS":[[[1.0,2.0],"City"]]"#)); + assert!(script.contains("window.location.host")); + } +} diff --git a/validator/src/main.rs b/validator/src/main.rs index ee9a5357..ed489a87 100644 --- a/validator/src/main.rs +++ b/validator/src/main.rs @@ -62,8 +62,6 @@ const MARSHAL_RESOLVER_TIMEOUT: Duration = Duration::from_secs(10); const BASE_MAX_MESSAGE_SIZE: u32 = 1024 * 1024; const MAX_FETCH_COUNT: usize = 16; const MAX_FETCH_SIZE: usize = 512 * 1024; -const BLOCKS_FREEZER_TABLE_INITIAL_SIZE: u32 = 2u32.pow(21); // 100MB -const FINALIZED_FREEZER_TABLE_INITIAL_SIZE: u32 = 2u32.pow(21); // 100MB fn configured_max_message_size(block_size: u32) -> u32 { // Block data contributes its bytes and the codec's variable-length prefix to each message. @@ -81,6 +79,32 @@ fn configured_max_message_size(block_size: u32) -> u32 { .expect("block size exceeds authenticated transport maximum") } +fn resolve_named_http_url(url: &str, hosts: &HashMap) -> String { + let Some((scheme, rest)) = url.split_once("://") else { + return url.to_string(); + }; + if !matches!(scheme, "http" | "https") { + return url.to_string(); + } + + let (authority, suffix) = match rest.split_once('/') { + Some((authority, suffix)) => (authority, format!("/{suffix}")), + None => (rest, String::new()), + }; + let (host, port) = match authority.rsplit_once(':') { + Some((host, port)) => (host, Some(port)), + None => (authority, None), + }; + let Some(ip) = hosts.get(host) else { + return url.to_string(); + }; + + match port { + Some(port) => format!("{scheme}://{ip}:{port}{suffix}"), + None => format!("{scheme}://{ip}{suffix}"), + } +} + fn main() { // Parse arguments let matches = Command::new("validator") @@ -101,7 +125,8 @@ fn main() { // Load config let config_file = matches.get_one::("config").unwrap(); let config_file = std::fs::read_to_string(config_file).expect("Could not read config file"); - let config: Config = serde_yaml::from_str(&config_file).expect("Could not parse config file"); + let mut config: Config = + serde_yaml::from_str(&config_file).expect("Could not parse config file"); let max_message_size = configured_max_message_size(config.block_size); let key = from_hex(&config.private_key).expect("Could not parse private key"); let signer = PrivateKey::decode(key.as_ref()).expect("Private key is invalid"); @@ -157,6 +182,14 @@ fn main() { std::fs::read_to_string(hosts_file).expect("Could not read hosts file"); serde_yaml::from_str::(&hosts_file).expect("Could not parse hosts file") }); + if let (Some(hosts), Some(indexer_url)) = (hosts.as_ref(), config.indexer.as_deref()) { + let hosts_by_name = hosts + .hosts + .iter() + .map(|host| (host.name.clone(), host.ip)) + .collect(); + config.indexer = Some(resolve_named_http_url(indexer_url, &hosts_by_name)); + } let traces = hosts.as_ref().map(|hosts| tokio::tracing::Config { endpoint: format!("http://{}:4318/v1/traces", hosts.monitoring.private), name: public_key.to_string(), @@ -178,13 +211,21 @@ fn main() { // Load peers let (ip, peers, bootstrappers) = if let Some(hosts) = hosts { - let peers: HashMap = hosts + let hosts_by_name: HashMap = hosts .hosts .into_iter() + .map(|host| (host.name, host.ip)) + .collect(); + let peers: HashMap = config + .allowed_peers + .iter() .map(|peer| { - let key = from_hex(&peer.name).expect("Could not parse peer key"); + let ip = hosts_by_name + .get(peer) + .expect("Could not find peer in hosts file"); + let key = from_hex(peer).expect("Could not parse peer key"); let key = PublicKey::decode(key.as_ref()).expect("Peer key is invalid"); - (key, peer.ip) + (key, *ip) }) .collect(); @@ -326,8 +367,6 @@ fn main() { blocker: oracle.clone(), provider: oracle.clone(), partition_prefix: "engine".to_string(), - blocks_freezer_table_initial_size: BLOCKS_FREEZER_TABLE_INITIAL_SIZE, - finalized_freezer_table_initial_size: FINALIZED_FREEZER_TABLE_INITIAL_SIZE, me: public_key.clone(), participants, mailbox_size: config.mailbox_size, @@ -397,4 +436,21 @@ mod tests { fn max_message_size_rejects_unsupported_block_size() { configured_max_message_size(u32::MAX); } + + #[test] + fn named_http_url_resolves_deployed_indexer() { + let hosts = HashMap::from([( + "indexer".to_string(), + "203.0.113.7".parse::().unwrap(), + )]); + + assert_eq!( + resolve_named_http_url("http://indexer:8080/consensus", &hosts), + "http://203.0.113.7:8080/consensus" + ); + assert_eq!( + resolve_named_http_url("https://external.example.com", &hosts), + "https://external.example.com" + ); + } } From eabcec1f0ce8e0494e37add76071613b6a9d8e8c Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Tue, 11 Aug 2026 22:21:19 -0700 Subject: [PATCH 2/9] nit --- deploy.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy.sh b/deploy.sh index 152c8418..ee7034d4 100755 --- a/deploy.sh +++ b/deploy.sh @@ -37,6 +37,7 @@ cargo run --locked --bin deploy -- generate \ --log-level info \ --mailbox-size 16384 \ --deque-size 256 \ + --block-size 0 \ --signature-threads 12 \ --leader-mode stable \ --leader-delay-ms 5 \ From 135a33294a4bead8a8f502892269d1d8f8dee6f4 Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Tue, 11 Aug 2026 23:57:37 -0700 Subject: [PATCH 3/9] support deploy --- deploy.sh | 73 +++++- deploy/src/main.rs | 13 +- explorer/package.json | 6 +- explorer/scripts/check-wasm.mjs | 58 +++++ explorer/src/App.tsx | 279 +++++++++++++-------- explorer/src/alto_types/alto_types.js | 70 +----- explorer/src/alto_types/alto_types_bg.wasm | Bin 248623 -> 247904 bytes explorer/src/config.ts | 1 + explorer/src/consensusWorker.ts | 44 ++++ explorer/src/consensusWorkerPool.test.ts | 104 ++++++++ explorer/src/consensusWorkerPool.ts | 146 +++++++++++ explorer/src/leaderLocation.test.ts | 27 ++ explorer/src/leaderLocation.ts | 27 ++ explorer/src/timeline.test.ts | 9 + explorer/src/timeline.ts | 4 + explorer/src/types.ts | 3 +- indexer/src/main.rs | 8 + types/src/wasm.rs | 4 + 18 files changed, 693 insertions(+), 183 deletions(-) create mode 100644 explorer/scripts/check-wasm.mjs create mode 100644 explorer/src/consensusWorker.ts create mode 100644 explorer/src/consensusWorkerPool.test.ts create mode 100644 explorer/src/consensusWorkerPool.ts create mode 100644 explorer/src/leaderLocation.test.ts create mode 100644 explorer/src/leaderLocation.ts create mode 100644 explorer/src/timeline.test.ts create mode 100644 explorer/src/timeline.ts diff --git a/deploy.sh b/deploy.sh index ee7034d4..8b2a10be 100755 --- a/deploy.sh +++ b/deploy.sh @@ -3,7 +3,7 @@ set -euo pipefail cd "$(dirname "$0")" -for tool in cargo just docker deployer npm; do +for tool in cargo just docker deployer npm wasm-pack; do if ! command -v "$tool" >/dev/null 2>&1; then echo "missing required command: $tool" >&2 exit 1 @@ -22,6 +22,29 @@ if [ ! -f deploy/dashboard.json ]; then exit 1 fi +explorer_build_env=() +if [ "$(uname -s)" = "Darwin" ]; then + if ! command -v brew >/dev/null 2>&1; then + echo "Homebrew LLVM is required to compile the explorer's WebAssembly on macOS" >&2 + exit 1 + fi + if ! llvm_prefix="$(brew --prefix llvm 2>/dev/null)"; then + echo "Homebrew LLVM is not installed; run: brew install llvm" >&2 + exit 1 + fi + wasm_clang="${llvm_prefix}/bin/clang" + wasm_ar="${llvm_prefix}/bin/llvm-ar" + if [ ! -x "$wasm_clang" ] || [ ! -x "$wasm_ar" ]; then + echo "Homebrew LLVM is missing clang or llvm-ar under ${llvm_prefix}/bin" >&2 + exit 1 + fi + if ! printf '' | "$wasm_clang" --target=wasm32-unknown-unknown -x c -c -o /dev/null -; then + echo "Homebrew clang cannot compile wasm32-unknown-unknown" >&2 + exit 1 + fi + explorer_build_env=("CC=${wasm_clang}" "AR=${wasm_ar}") +fi + if [ -d assets ]; then read -r -p "./assets exists — remove and regenerate? [y/N] " answer [ "$answer" = "y" ] || exit 1 @@ -34,6 +57,7 @@ cargo run --locked --bin deploy -- generate \ --peers 50 \ --bootstrappers 5 \ --worker-threads 4 \ + --network-buffer-pool-max-per-class 16384 \ --log-level info \ --mailbox-size 16384 \ --deque-size 256 \ @@ -59,8 +83,53 @@ for artifact in config.yaml dashboard.json indexer.yaml; do fi done +network_key_check_dir="$(mktemp -d)" +cleanup_network_key_check() { + rm -rf -- "$network_key_check_dir" +} +trap cleanup_network_key_check EXIT +first_validator_config="$(awk ' + $1 == "binary:" { binary = $2; next } + $1 == "config:" && binary == "validator" { print $2; exit } +' assets/config.yaml)" +if [ -z "$first_validator_config" ] || [ ! -f "assets/${first_validator_config}" ]; then + echo "deployment does not contain a readable validator config" >&2 + exit 1 +fi +cp assets/config.yaml "$network_key_check_dir/config.yaml" +cp "assets/${first_validator_config}" "$network_key_check_dir/${first_validator_config}" +cargo run --quiet --locked --bin deploy -- explorer \ + --dir "$network_key_check_dir" \ + --backend-url unused.invalid \ + remote >/dev/null +derived_network_identity="$(sed -n 's/^export const PUBLIC_KEY_HEX = "\(.*\)";$/\1/p' "$network_key_check_dir/config.ts")" +deployed_network_identity="$(sed -n 's/^identity: //p' assets/indexer.yaml)" +if [ -z "$derived_network_identity" ] || [ "$derived_network_identity" != "$deployed_network_identity" ]; then + echo "indexer explorer identity does not match the validator network polynomial" >&2 + exit 1 +fi +cleanup_network_key_check +trap - EXIT + +validator_config_count=0 +while IFS= read -r validator_config; do + validator_config_count=$((validator_config_count + 1)) + if ! grep -qx 'network_buffer_pool_max_per_class: 16384' "assets/${validator_config}"; then + echo "validator config has an unexpected network buffer pool limit: assets/${validator_config}" >&2 + exit 1 + fi +done < <(awk ' + $1 == "binary:" { binary = $2; next } + $1 == "config:" && binary == "validator" { print $2 } +' assets/config.yaml) +if [ "$validator_config_count" -ne 50 ]; then + echo "expected 50 validator configs, found ${validator_config_count}" >&2 + exit 1 +fi + npm --prefix explorer ci -GENERATE_SOURCEMAP=false npm --prefix explorer run build:react +CI=true npm --prefix explorer test -- --watchAll=false --runInBand +env "${explorer_build_env[@]}" GENERATE_SOURCEMAP=false npm --prefix explorer run build if [ ! -f explorer/build/index.html ]; then echo "explorer build did not create explorer/build/index.html" >&2 exit 1 diff --git a/deploy/src/main.rs b/deploy/src/main.rs index b22e259f..93b5cdd5 100644 --- a/deploy/src/main.rs +++ b/deploy/src/main.rs @@ -83,6 +83,7 @@ struct DeployedIndexerConfig { struct DeployedExplorerConfig { name: String, description: String, + participants: Vec, locations: Vec<([f64; 2], String)>, } @@ -837,13 +838,16 @@ fn generate_remote( } let deployed_indexer_config = deploy_indexer.then(|| { - let locations = instance_configs + let (participants, locations) = instance_configs .iter() .map(|instance| { - get_aws_location(&instance.region) - .unwrap_or_else(|| panic!("unknown AWS region: {}", instance.region)) + ( + instance.name.clone(), + get_aws_location(&instance.region) + .unwrap_or_else(|| panic!("unknown AWS region: {}", instance.region)), + ) }) - .collect(); + .unzip(); let unique_regions = regions.iter().fold(Vec::new(), |mut unique, region| { if !unique.contains(region) { unique.push(region.clone()); @@ -861,6 +865,7 @@ fn generate_remote( unique_regions.len(), unique_regions.join(", ") ), + participants, locations, }, } diff --git a/explorer/package.json b/explorer/package.json index a096606b..8ae7fa0e 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -15,8 +15,10 @@ }, "scripts": { "start": "react-scripts start", + "test": "react-scripts test", "build:wasm": "cd ../types && wasm-pack build --target web --out-dir ../explorer/src/alto_types --out-name alto_types", - "build": "npm run build:wasm && react-scripts build", + "test:wasm": "node scripts/check-wasm.mjs", + "build": "npm run build:wasm && npm run test:wasm && react-scripts build", "build:react": "react-scripts build" }, "eslintConfig": { @@ -48,4 +50,4 @@ "devDependencies": { "@types/leaflet": "^1.9.16" } -} \ No newline at end of file +} diff --git a/explorer/scripts/check-wasm.mjs b/explorer/scripts/check-wasm.mjs new file mode 100644 index 00000000..ee0d0f7e --- /dev/null +++ b/explorer/scripts/check-wasm.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const generatedDirectory = new URL("../src/alto_types/", import.meta.url); +const glue = await readFile(new URL("alto_types.js", generatedDirectory), "utf8"); +const moduleUrl = `data:text/javascript;base64,${Buffer.from(glue).toString("base64")}`; +const altoTypes = await import(moduleUrl); +const binary = await readFile(new URL("alto_types_bg.wasm", generatedDirectory)); +await altoTypes.default({ module_or_path: binary }); + +const fromHex = (hex) => Uint8Array.from( + hex.match(/../g).map((byte) => Number.parseInt(byte, 16)), +); + +const identity = fromHex( + "90577f9eefba4e54f98aa6cda386718e82863d4989b7282f09446bdb408469dc" + + "3dec0097ef3dc9359d88b04d700290e902628bc56fe9295ad92794114a65b2ee" + + "1985767a7d230bd02d99080f6fcd1e47e5398e5ea9198b87614491543e25a5c0", +); + +// These fixed, public network artifacts catch generated WASM that no longer +// decodes and verifies the certificate wire format used by the indexer. +const artifacts = [ + [ + "seed", + altoTypes.parse_seed, + "00e1d5568b2e909f067906d21308d8fc080507660e1c217926c172d8cd7ea12f" + + "19b9c12f2b89a10e639b39c9a44b093c8b982cf1", + ], + [ + "notarization", + altoTypes.parse_notarized, + "00e8d556e7d55663ad946a51587556b3aad0f5605c82e5591301e8c59ed49b7d9c" + + "aac7654180dc8d75ac0df306528f348898c6f4c10da01544647dfd23ab20f7091240" + + "e5313cb07f3a45c8280950635b6fa19ae4f0bad296a2da6f7bba3d58e47357e3ce" + + "f418a841f761929cc99ca271e6ff558f2db359f3a81c246834c1efe75e0b9fa4e4c" + + "c0e00e8d55658c38457d86e3b5ad4b63d3141d549092af777ead207cdcdf2e020fb" + + "e22f9471e7d556e85a83d7c80a460b11675d77464e010a20fa7b99adba13429abc" + + "b8818e433c8de85a83d7c80a460b11675d77464e010a20fa7b99adba13429abcb8" + + "818e433c8d88b307fae8f7a3ff3300", + ], + [ + "finalization", + altoTypes.parse_finalized, + "00b3d556b2d556b285175036544e0b5792fc8d12b7a5de8db5d1fe90477529c4e11" + + "6db2f606d0c8a41e7f66520148f37f717a0f5f984e151f5202da84814d9934027f" + + "d5b8968feb97dd54219a19ca35433d87d3d6477c4b1813f29d9feb783fd3a686c2" + + "81f07d571d283363293f74f30e0fee389ca3ccda4b8dc63f5c6a75fb340ba8994f" + + "3fb0d00b3d55658c38457d86e3b5ad4b63d3141d549092af777ead207cdcdf2e020" + + "fbe22f9471b2d556279ae1cdb53c51e3a05017b414edd9891a4df11cce61cd2daac" + + "d3a0e0b833d1a279ae1cdb53c51e3a05017b414edd9891a4df11cce61cd2daacd3" + + "a0e0b833d1ad3b207e6e6f7a3ff3300", + ], +]; + +for (const [name, parse, encoded] of artifacts) { + assert.notEqual(parse(identity, fromHex(encoded)), null, `${name} did not verify`); +} diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index fd8b9008..dbd7357c 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from "react" import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet"; import { DivIcon, LatLng } from "leaflet"; import "leaflet/dist/leaflet.css"; -import init, { parse_seed, parse_notarized, parse_finalized, leader_index } from "./alto_types/alto_types.js"; +import init, { leader_index } from "./alto_types/alto_types.js"; import { getClusterConfig, getClusters, @@ -14,6 +14,13 @@ import { } from "./config"; import { SeedJs, NotarizedJs, FinalizedJs, ViewData } from "./types"; import { hexToUint8Array, hexUint8Array } from "./utils"; +import { resolveLeaderLocation } from "./leaderLocation"; +import { + ConsensusWorkerPool, + consensusWorkerCount, + VerifiedConsensusArtifact, +} from "./consensusWorkerPool"; +import { scaleTimelineWidth } from "./timeline"; import "./App.css"; import AboutModal from './AboutModal'; import './AboutModal.css'; @@ -44,9 +51,21 @@ const getInitialCluster = (): Cluster => { return DEFAULT_CLUSTER; }; -const SCALE_DURATION = 500; // 500ms const TIMEOUT_DURATION = 5000; // 5s const HEALTH_CHECK_INTERVAL = 60000; // Check health every minute +const VIEW_RENDER_INTERVAL = 100; +const MAX_TRACKED_VIEWS = 128; +const MAX_RENDERED_VIEWS = 50; + +const retainNewestViews = (views: ViewData[]): ViewData[] => { + const sorted = [...views].sort((a, b) => b.view - a.view); + for (const discarded of sorted.slice(MAX_TRACKED_VIEWS)) { + if (discarded.timeoutId) { + clearTimeout(discarded.timeoutId); + } + } + return sorted.slice(0, MAX_TRACKED_VIEWS); +}; const center = new LatLng(0, 0); const markerIcon = new DivIcon({ @@ -97,11 +116,20 @@ const App: React.FC = () => { const [selectedCluster, setSelectedCluster] = useState(getInitialCluster()); const clusterConfig = useMemo(() => getClusterConfig(selectedCluster), [selectedCluster]); const allConfigs = useMemo(() => getClusters(), []); - const { BACKEND_URL, PUBLIC_KEY_HEX, LOCATIONS } = clusterConfig; + const { BACKEND_URL, PUBLIC_KEY_HEX, LOCATIONS, PARTICIPANTS } = clusterConfig; const PUBLIC_KEY = useMemo(() => hexToUint8Array(PUBLIC_KEY_HEX), [PUBLIC_KEY_HEX]); const [views, setViews] = useState([]); - const [lastObservedView, setLastObservedView] = useState(null); + const mappedView = useMemo( + () => views.reduce((latest, view) => { + if (view.location === undefined || (latest && latest.view >= view.view)) { + return latest; + } + return view; + }, undefined), + [views], + ); + const lastObservedViewRef = useRef(null); const [isAboutModalOpen, setIsAboutModalOpen] = useState(false); const [isKeyInfoModalOpen, setIsKeyInfoModalOpen] = useState(false); const [isMobile, setIsMobile] = useState(false); @@ -114,6 +142,8 @@ const App: React.FC = () => { const adjustTime = useClockSkew(); const currentTimeRef = useRef(adjustTime(Date.now())); const wsRef = useRef(null); + const verifierPoolRef = useRef(null); + const verifiedQueueRef = useRef([]); // Manage WebSocket lifecycle const handleSeedRef = useRef(null!); @@ -169,7 +199,8 @@ const App: React.FC = () => { // Reset state when the cluster changes useEffect(() => { setViews([]); - setLastObservedView(null); + lastObservedViewRef.current = null; + verifiedQueueRef.current = []; setErrorMessage(""); setShowError(false); }, [selectedCluster]); @@ -248,12 +279,26 @@ const App: React.FC = () => { }; }, []); - const handleSeed = useCallback((seed: SeedJs) => { + const resolveSeedLocation = useCallback((seed: SeedJs) => { + if (MODE !== 'public' || PARTICIPANTS?.length || LOCATIONS.length === 0) { + return { location: undefined, locationName: undefined }; + } + + const locationIndex = leader_index(seed, LOCATIONS.length); + return { + location: LOCATIONS[locationIndex][0], + locationName: LOCATIONS[locationIndex][1], + }; + }, [LOCATIONS, PARTICIPANTS]); + + const handleSeed = useCallback((seed: SeedJs, receivedAt: number) => { const view = seed.view + 1; // Next view is determined by seed - 1 + const observedAt = adjustTime(receivedAt); setViews((prevViews) => { // Create a copy of the current views that we'll modify let newViews = [...prevViews]; + const lastObservedView = lastObservedViewRef.current; // If we haven't observed any views yet, or if the new view is greater than the last observed view + 1, // handle potentially missed views @@ -286,7 +331,7 @@ const App: React.FC = () => { location: undefined, locationName: undefined, status: "unknown", - startTime: adjustTime(Date.now()), + startTime: observedAt, timeoutId: timeoutId }); } @@ -301,16 +346,13 @@ const App: React.FC = () => { // the location and signature information without changing timing const existingStatus = newViews[existingIndex].status; if (existingStatus === "finalized" || existingStatus === "notarized") { - // Only update location if in public mode - const locationIndex = MODE === 'public' ? leader_index(seed, LOCATIONS.length) : -1; - const location = locationIndex >= 0 ? LOCATIONS[locationIndex][0] : undefined; - const locationName = locationIndex >= 0 ? LOCATIONS[locationIndex][1] : undefined; + const { location, locationName } = resolveSeedLocation(seed); // Only update location and signature info, preserve all timing and status newViews[existingIndex] = { ...newViews[existingIndex], - location, - locationName, + location: location ?? newViews[existingIndex].location, + locationName: locationName ?? newViews[existingIndex].locationName, signature: seed.signature, }; @@ -329,15 +371,13 @@ const App: React.FC = () => { } // Create the new view data - const locationIndex = MODE === 'public' ? leader_index(seed, LOCATIONS.length) : -1; - const location = locationIndex >= 0 ? LOCATIONS[locationIndex][0] : undefined; - const locationName = locationIndex >= 0 ? LOCATIONS[locationIndex][1] : undefined; + const { location, locationName } = resolveSeedLocation(seed); const newView: ViewData = { view, location, locationName, status: "growing", - startTime: adjustTime(Date.now()), + startTime: observedAt, signature: seed.signature, }; @@ -375,26 +415,16 @@ const App: React.FC = () => { // Update the last observed view if this is a new maximum if (lastObservedView === null || view > lastObservedView) { - setLastObservedView(view); + lastObservedViewRef.current = view; } - // Limit the number of views to 50 - if (newViews.length > 50) { - // Clean up any timeouts for views we're about to remove - for (let i = 50; i < newViews.length; i++) { - if (newViews[i].timeoutId) { - clearTimeout(newViews[i].timeoutId); - } - } - newViews = newViews.slice(0, 50); - } - - return newViews; + return retainNewestViews(newViews); }); - }, [lastObservedView, adjustTime, LOCATIONS]); + }, [adjustTime, resolveSeedLocation]); - const handleNotarization = useCallback((notarized: NotarizedJs) => { + const handleNotarization = useCallback((notarized: NotarizedJs, receivedAt: number) => { const view = notarized.proof.view; + const leaderLocation = resolveLeaderLocation(notarized.block?.leader, PARTICIPANTS, LOCATIONS); setViews((prevViews) => { const index = prevViews.findIndex((v) => v.view === view); @@ -403,7 +433,7 @@ const App: React.FC = () => { return prevViews; // No changes needed, preserve finalized state } let newViews = [...prevViews]; - const currentTime = adjustTime(Date.now()); + const currentTime = adjustTime(receivedAt); // Calculate a reasonable start time using the block timestamp if available let calculatedStartTime = currentTime; @@ -439,6 +469,7 @@ const App: React.FC = () => { block: viewData.block || notarized.block, // Don't overwrite existing block data timeoutId: undefined, actualNotarizationLatency, + ...leaderLocation, }; newViews = [ @@ -457,8 +488,8 @@ const App: React.FC = () => { } newViews = [{ view, - location: undefined, - locationName: undefined, + location: leaderLocation?.location, + locationName: leaderLocation?.locationName, status: "notarized", startTime: calculatedStartTime, notarizationTime: currentTime, @@ -467,27 +498,17 @@ const App: React.FC = () => { }, ...prevViews]; } - // Limit the number of views to 50 - if (newViews.length > 50) { - // Clean up any timeouts for views we're about to remove - for (let i = 50; i < newViews.length; i++) { - if (newViews[i].timeoutId) { - clearTimeout(newViews[i].timeoutId); - } - } - newViews = newViews.slice(0, 50); - } - - return newViews; + return retainNewestViews(newViews); }); - }, [adjustTime]); + }, [adjustTime, LOCATIONS, PARTICIPANTS]); - const handleFinalization = useCallback((finalized: FinalizedJs) => { + const handleFinalization = useCallback((finalized: FinalizedJs, receivedAt: number) => { const view = finalized.proof.view; + const leaderLocation = resolveLeaderLocation(finalized.block?.leader, PARTICIPANTS, LOCATIONS); setViews((prevViews) => { const index = prevViews.findIndex((v) => v.view === view); let newViews = [...prevViews]; - const currentTime = adjustTime(Date.now()); + const currentTime = adjustTime(receivedAt); // Calculate a reasonable start time using the block timestamp if available let calculatedStartTime = currentTime; @@ -530,6 +551,7 @@ const App: React.FC = () => { timeoutId: undefined, actualNotarizationLatency: viewData.actualNotarizationLatency, actualFinalizationLatency, + ...leaderLocation, }; newViews = [ @@ -548,8 +570,8 @@ const App: React.FC = () => { } newViews = [{ view, - location: undefined, - locationName: undefined, + location: leaderLocation?.location, + locationName: leaderLocation?.locationName, status: "finalized", startTime: calculatedStartTime, // No notarization time observed yet @@ -559,28 +581,35 @@ const App: React.FC = () => { }, ...prevViews]; } - // Limit the number of views to 50 - if (newViews.length > 50) { - // Clean up any timeouts for views we're about to remove - for (let i = 50; i < newViews.length; i++) { - if (newViews[i].timeoutId) { - clearTimeout(newViews[i].timeoutId); - } - } - newViews = newViews.slice(0, 50); - } - - return newViews; + return retainNewestViews(newViews); }); - }, [adjustTime]); + }, [adjustTime, LOCATIONS, PARTICIPANTS]); - // Update current time every 50ms to force re-render for growing bars + // Merge every verified artifact while limiting React rendering work. useEffect(() => { const interval = setInterval(() => { currentTimeRef.current = adjustTime(Date.now()); - // Force re-render without relying on state updates - setViews(views => [...views]); - }, 50); + const verified = verifiedQueueRef.current.splice(0); + for (const { kind, artifact, receivedAt } of verified) { + if (!artifact) { + continue; + } + switch (kind) { + case 0: + handleSeedRef.current(artifact as SeedJs, receivedAt); + break; + case 1: + handleNotarizedRef.current(artifact as NotarizedJs, receivedAt); + break; + case 2: + handleFinalizedRef.current(artifact as FinalizedJs, receivedAt); + break; + } + } + if (verified.length === 0) { + setViews(views => [...views]); + } + }, VIEW_RENDER_INTERVAL); return () => clearInterval(interval); }, [adjustTime]); @@ -620,8 +649,12 @@ const App: React.FC = () => { // Skip if already initialized to prevent duplicate connections during development mode's double-invocation if (isInitializedRef.current) return; isInitializedRef.current = true; + let cancelled = false; const connectWebSocket = () => { + if (cancelled) { + return; + } // Clear any existing reconnection timers if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); @@ -646,6 +679,10 @@ const App: React.FC = () => { ws.binaryType = "arraybuffer"; ws.onopen = () => { + if (cancelled) { + ws.close(1000, "Component unmounted"); + return; + } console.log(`WebSocket connected: ${BACKEND_URL}`); setErrorMessage(""); setShowError(false); @@ -654,20 +691,8 @@ const App: React.FC = () => { ws.onmessage = (event) => { const data = new Uint8Array(event.data); const kind = data[0]; - const payload = data.slice(1); - switch (kind) { - case 0: // Seed - const seed = parse_seed(PUBLIC_KEY, payload); - if (seed) handleSeedRef.current(seed); - break; - case 1: // Notarization - const notarized = parse_notarized(PUBLIC_KEY, payload); - if (notarized) handleNotarizedRef.current(notarized); - break; - case 2: // Finalization - const finalized = parse_finalized(PUBLIC_KEY, payload); - if (finalized) handleFinalizedRef.current(finalized); - break; + if (kind <= 2) { + verifierPoolRef.current?.verify(kind, data.slice(1), Date.now()); } }; @@ -676,6 +701,9 @@ const App: React.FC = () => { }; ws.onclose = (event) => { + if (cancelled) { + return; + } console.error(`WebSocket closed with code: ${event.code}`); // Check for potential rate limiting (code 1006 is "Abnormal Closure") @@ -698,7 +726,9 @@ const App: React.FC = () => { if (wsRef.current === ws) { reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = null; - connectWebSocket(); + if (!cancelled) { + connectWebSocket(); + } }, 11000); } }; @@ -706,19 +736,60 @@ const App: React.FC = () => { const setup = async () => { await init(); + if (cancelled) { + return; + } + const createWorker = () => new Worker(new URL("./consensusWorker.ts", import.meta.url)); + const workers: Worker[] = []; + try { + for (let index = 0; index < consensusWorkerCount(navigator.hardwareConcurrency || 4); index++) { + workers.push(createWorker()); + } + } catch (error) { + workers.forEach((worker) => worker.terminate()); + throw error; + } + if (cancelled) { + workers.forEach((worker) => worker.terminate()); + return; + } + verifierPoolRef.current = new ConsensusWorkerPool( + workers, + PUBLIC_KEY, + (verified) => verifiedQueueRef.current.push(verified), + () => { + setErrorMessage("A consensus verifier stopped unexpectedly. Refresh to reconnect."); + setShowError(true); + }, + createWorker, + ); connectWebSocket(); }; - setup(); + setup().catch((error) => { + if (cancelled) { + return; + } + console.error("Unable to initialize consensus verifiers:", error); + isInitializedRef.current = false; + setErrorMessage("Consensus verification could not start. Refresh to retry."); + setShowError(true); + }); // Cleanup function when component unmounts return () => { + cancelled = true; + isInitializedRef.current = false; // Clear any reconnection timers if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } + verifierPoolRef.current?.terminate(); + verifierPoolRef.current = null; + verifiedQueueRef.current = []; + // Close and clean up the websocket if (wsRef.current) { const ws = wsRef.current; @@ -821,22 +892,22 @@ const App: React.FC = () => { url="https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png" attribution='© OSM | © CARTO' /> - {views.length > 0 && views[0].location !== undefined && ( + {mappedView?.location !== undefined && (
- View: {views[0].view}
- Location: {views[0].locationName}
- Status: {views[0].status}
- {views[0].block && ( - <>Block Height: {views[0].block.height}
+ View: {mappedView.view}
+ Location: {mappedView.locationName}
+ Status: {mappedView.status}
+ {mappedView.block && ( + <>Block Height: {mappedView.block.height}
)} - {views[0].startTime && ( - <>Start Time: {new Date(views[0].startTime).toLocaleTimeString()}
+ {mappedView.startTime && ( + <>Start Time: {new Date(mappedView.startTime).toLocaleTimeString()}
)}
@@ -867,7 +938,7 @@ const App: React.FC = () => {
- {views.slice(0, 50).map((viewData) => ( + {views.slice(0, MAX_RENDERED_VIEWS).map((viewData) => ( = ({ viewData, currentTime, isMobile }) => { } } - // Now calculate bar widths based on the actual latency values - const calculateScaledWidth = (latency: number) => { - // Apply scaling factor to keep bars within reasonable size - return Math.min(latency / SCALE_DURATION, 1) * measuredWidth; - }; - // Calculate the widths for different bar segments if (status === "growing" || status === "unknown") { - totalWidth = calculateScaledWidth(growingLatency); + totalWidth = scaleTimelineWidth(growingLatency, measuredWidth); // Ensure growing bars are visible but don't exceed available width totalWidth = Math.min(Math.max(totalWidth, growingLatency > 50 ? minSegmentWidth : 0), measuredWidth); } else if (status === "notarized") { - totalWidth = calculateScaledWidth(notarizedLatency); + totalWidth = scaleTimelineWidth(notarizedLatency, measuredWidth); // Ensure notarized bars meet minimum width totalWidth = Math.max(totalWidth, minBarWidth); } else if (status === "finalized") { if (notarizationTime) { // Calculate notarized segment width - notarizedWidth = calculateScaledWidth(notarizedLatency); + notarizedWidth = scaleTimelineWidth(notarizedLatency, measuredWidth); notarizedWidth = Math.max(notarizedWidth, minSegmentWidth); // Calculate finalized segment width (difference between finalization and notarization) const finalizationDelta = finalizedLatency - notarizedLatency; if (finalizationDelta > 0) { - finalizedWidth = calculateScaledWidth(finalizationDelta); + finalizedWidth = scaleTimelineWidth(finalizationDelta, measuredWidth); finalizedWidth = Math.max(finalizedWidth, minSegmentWidth / 2); } totalWidth = notarizedWidth + finalizedWidth; } else { // Without notarization time, use the entire bar for finalization - totalWidth = calculateScaledWidth(finalizedLatency); + totalWidth = scaleTimelineWidth(finalizedLatency, measuredWidth); totalWidth = Math.max(totalWidth, minBarWidth); } } else if (status === "timed_out") { diff --git a/explorer/src/alto_types/alto_types.js b/explorer/src/alto_types/alto_types.js index 7710e2dc..ae99b1f5 100644 --- a/explorer/src/alto_types/alto_types.js +++ b/explorer/src/alto_types/alto_types.js @@ -108,10 +108,6 @@ function __wbg_get_imports() { const ret = typeof(val) === 'object' && val !== null; return ret; }, - __wbg___wbindgen_is_string_c236cabd84a4d769: function(arg0) { - const ret = typeof(arg0) === 'string'; - return ret; - }, __wbg___wbindgen_is_undefined_67b456be8673d3d7: function(arg0) { const ret = arg0 === undefined; return ret; @@ -145,20 +141,12 @@ function __wbg_get_imports() { const ret = arg0.call(arg1); return ret; }, arguments); }, - __wbg_call_9c758de292015997: function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.call(arg1, arg2); - return ret; - }, arguments); }, - __wbg_crypto_38df2bab126b63dc: function(arg0) { - const ret = arg0.crypto; - return ret; - }, __wbg_done_60cf307fcc680536: function(arg0) { const ret = arg0.done; return ret; }, - __wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) { - arg0.getRandomValues(arg1); + __wbg_getRandomValues_cc7f052a444bb2ce: function() { return handleError(function (arg0, arg1) { + globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1)); }, arguments); }, __wbg_get_1f8f054ddbaa7db2: function() { return handleError(function (arg0, arg1) { const ret = Reflect.get(arg0, arg1); @@ -212,10 +200,6 @@ function __wbg_get_imports() { const ret = arg0.length; return ret; }, - __wbg_msCrypto_bd5a034af96bcba6: function(arg0) { - const ret = arg0.msCrypto; - return ret; - }, __wbg_new_578aeef4b6b94378: function(arg0) { const ret = new Uint8Array(arg0); return ret; @@ -228,10 +212,6 @@ function __wbg_get_imports() { const ret = new Array(); return ret; }, - __wbg_new_with_length_36a4998e27b014c5: function(arg0) { - const ret = new Uint8Array(arg0 >>> 0); - return ret; - }, __wbg_next_9e03acdf51c4960d: function(arg0) { const ret = arg0.next; return ret; @@ -240,74 +220,30 @@ function __wbg_get_imports() { const ret = arg0.next(); return ret; }, arguments); }, - __wbg_node_84ea875411254db1: function(arg0) { - const ret = arg0.node; - return ret; - }, - __wbg_process_44c7a14e11e9f69e: function(arg0) { - const ret = arg0.process; - return ret; - }, __wbg_prototypesetcall_3249fc62a0fafa30: function(arg0, arg1, arg2) { Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); }, - __wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) { - arg0.randomFillSync(arg1); - }, arguments); }, - __wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () { - const ret = module.require; - return ret; - }, arguments); }, __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) { arg0[arg1] = arg2; }, __wbg_set_dca99999bba88a9a: function(arg0, arg1, arg2) { arg0[arg1 >>> 0] = arg2; }, - __wbg_static_accessor_GLOBAL_9d53f2689e622ca1: function() { - const ret = typeof global === 'undefined' ? null : global; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }, - __wbg_static_accessor_GLOBAL_THIS_a1a35cec07001a8a: function() { - const ret = typeof globalThis === 'undefined' ? null : globalThis; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }, - __wbg_static_accessor_SELF_4c59f6c7ea29a144: function() { - const ret = typeof self === 'undefined' ? null : self; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }, - __wbg_static_accessor_WINDOW_e70ae9f2eb052253: function() { - const ret = typeof window === 'undefined' ? null : window; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }, - __wbg_subarray_4aa221f6a4f5ab22: function(arg0, arg1, arg2) { - const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); - return ret; - }, __wbg_value_f3625092ee4b37f4: function(arg0) { const ret = arg0.value; return ret; }, - __wbg_versions_276b2795b1c6a219: function(arg0) { - const ret = arg0.versions; - return ret; - }, __wbindgen_cast_0000000000000001: function(arg0) { // Cast intrinsic for `F64 -> Externref`. const ret = arg0; return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. - const ret = getArrayU8FromWasm0(arg0, arg1); - return ret; - }, - __wbindgen_cast_0000000000000003: function(arg0, arg1) { // Cast intrinsic for `Ref(String) -> Externref`. const ret = getStringFromWasm0(arg0, arg1); return ret; }, - __wbindgen_cast_0000000000000004: function(arg0) { + __wbindgen_cast_0000000000000003: function(arg0) { // Cast intrinsic for `U64 -> Externref`. const ret = BigInt.asUintN(64, arg0); return ret; diff --git a/explorer/src/alto_types/alto_types_bg.wasm b/explorer/src/alto_types/alto_types_bg.wasm index 1e477035d4b1c63bfd474cb531291975f83fbf5a..18cd0b0d96e662855d9129cd20e18f683465a324 100644 GIT binary patch literal 247904 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%0D~ZeX*7jxZT?B1oIba*zm%pWSDS`}kC5;W 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

lixPbkwjtE4}K4{-T9vT#dyx~uGY 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

brO_vi^oKDAQl)udfmO5tb)6{kC3 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�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 + +import init, { + parse_finalized, + parse_notarized, + parse_seed, +} from "./alto_types/alto_types.js"; + +let publicKey: Uint8Array | undefined; +const initialized = init(); +const worker = globalThis as unknown as DedicatedWorkerGlobalScope; + +worker.onmessage = async (event: MessageEvent) => { + if (event.data.type === "initialize") { + publicKey = event.data.publicKey; + return; + } + if (event.data.type !== "verify") { + return; + } + + await initialized; + if (!publicKey) { + throw new Error("consensus verifier was not initialized"); + } + + const { sequence, kind, payload, receivedAt } = event.data; + let artifact = null; + switch (kind) { + case 0: + artifact = parse_seed(publicKey, payload); + break; + case 1: + artifact = parse_notarized(publicKey, payload); + break; + case 2: + artifact = parse_finalized(publicKey, payload); + break; + } + + worker.postMessage({ sequence, kind, artifact, receivedAt }); +}; + +export {}; diff --git a/explorer/src/consensusWorkerPool.test.ts b/explorer/src/consensusWorkerPool.test.ts new file mode 100644 index 00000000..c466d737 --- /dev/null +++ b/explorer/src/consensusWorkerPool.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "@jest/globals"; +import { ConsensusWorkerPool, consensusWorkerCount } from "./consensusWorkerPool"; + +class FakeWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + messages: unknown[] = []; + completed = 0; + terminated = false; + + postMessage(message: unknown) { + this.messages.push(message); + } + + terminate() { + this.terminated = true; + } + + fail() { + this.onerror?.({ preventDefault: () => undefined } as unknown as ErrorEvent); + } + + completeNext() { + const message = this.messages.slice(1)[this.completed] as any; + this.completed += 1; + this.onmessage?.({ + data: { + sequence: message.sequence, + kind: message.kind, + artifact: null, + receivedAt: message.receivedAt, + }, + } as MessageEvent); + } +} + +test("dispatches every artifact across the verifier pool without sampling", () => { + const workers = [new FakeWorker(), new FakeWorker(), new FakeWorker()]; + const verifiedReceivedAt: number[] = []; + const pool = new ConsensusWorkerPool( + workers as unknown as Worker[], + new Uint8Array([1, 2, 3]), + (result) => verifiedReceivedAt.push(result.receivedAt), + ); + + for (let id = 0; id < 8; id++) { + pool.verify(id % 3, new Uint8Array([id]), id); + } + + expect(workers.map((worker) => worker.messages.slice(1).length)).toEqual([1, 1, 1]); + + // A worker that finishes early immediately takes the next queued artifact. + for (let id = 0; id < 6; id++) { + workers[0].completeNext(); + } + workers[1].completeNext(); + workers[2].completeNext(); + + const verificationMessages = workers.flatMap((worker) => worker.messages.slice(1)); + expect(verificationMessages).toHaveLength(8); + expect(verificationMessages.map((message: any) => message.payload[0]).sort()).toEqual([ + 0, 1, 2, 3, 4, 5, 6, 7, + ]); + expect(verifiedReceivedAt).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + + pool.terminate(); + expect(workers.every((worker) => worker.terminated)).toBe(true); +}); + +test("reserves a core while using enough parallel verifiers", () => { + expect(consensusWorkerCount(2)).toBe(2); + expect(consensusWorkerCount(8)).toBe(7); + expect(consensusWorkerCount(18)).toBe(16); + expect(consensusWorkerCount(32)).toBe(16); +}); + +test("retries an in-flight artifact on a replacement worker", () => { + const worker = new FakeWorker(); + const replacements: FakeWorker[] = []; + const verifiedReceivedAt: number[] = []; + const errors: ErrorEvent[] = []; + const pool = new ConsensusWorkerPool( + [worker] as unknown as Worker[], + new Uint8Array([1, 2, 3]), + (result) => verifiedReceivedAt.push(result.receivedAt), + (error) => errors.push(error), + () => { + const replacement = new FakeWorker(); + replacements.push(replacement); + return replacement as unknown as Worker; + }, + ); + + pool.verify(1, new Uint8Array([9]), 42); + worker.fail(); + + expect(worker.terminated).toBe(true); + expect(replacements).toHaveLength(1); + expect((replacements[0].messages[1] as any).payload[0]).toBe(9); + + replacements[0].completeNext(); + expect(verifiedReceivedAt).toEqual([42]); + expect(errors).toEqual([]); +}); diff --git a/explorer/src/consensusWorkerPool.ts b/explorer/src/consensusWorkerPool.ts new file mode 100644 index 00000000..555a9fb0 --- /dev/null +++ b/explorer/src/consensusWorkerPool.ts @@ -0,0 +1,146 @@ +import type { FinalizedJs, NotarizedJs, SeedJs } from "./types"; + +export type ConsensusArtifact = SeedJs | NotarizedJs | FinalizedJs; + +export interface VerifiedConsensusArtifact { + kind: number; + artifact: ConsensusArtifact | null; + receivedAt: number; +} + +export const consensusWorkerCount = (hardwareConcurrency: number): number => + Math.min(16, Math.max(2, hardwareConcurrency - 1)); + +interface VerificationJob { + sequence: number; + kind: number; + payload: Uint8Array; + receivedAt: number; + attempts: number; +} + +interface WorkerVerifiedConsensusArtifact extends VerifiedConsensusArtifact { + sequence: number; +} + +export class ConsensusWorkerPool { + private readonly availableWorkers: Worker[]; + private readonly queuedJobs: VerificationJob[] = []; + private readonly completedResults = new Map(); + private readonly activeJobs = new Map(); + private nextJobSequence = 0; + private nextResultSequence = 0; + private stopped = false; + + constructor( + private readonly workers: Worker[], + private readonly publicKey: Uint8Array, + private readonly onVerified: (result: VerifiedConsensusArtifact) => void, + private readonly onError?: (event: ErrorEvent) => void, + private readonly createWorker?: () => Worker, + ) { + this.availableWorkers = []; + for (const worker of workers) { + this.configureWorker(worker); + this.availableWorkers.push(worker); + } + } + + private configureWorker(worker: Worker) { + worker.onmessage = (event: MessageEvent) => { + if (this.stopped) { + return; + } + const { sequence, kind, artifact, receivedAt } = event.data; + const activeJob = this.activeJobs.get(worker); + if (!activeJob || activeJob.sequence !== sequence) { + return; + } + this.activeJobs.delete(worker); + this.completedResults.set(sequence, { kind, artifact, receivedAt }); + while (this.completedResults.has(this.nextResultSequence)) { + this.onVerified(this.completedResults.get(this.nextResultSequence)!); + this.completedResults.delete(this.nextResultSequence); + this.nextResultSequence += 1; + } + this.availableWorkers.push(worker); + this.dispatch(); + }; + worker.onerror = (event) => this.handleWorkerError(worker, event); + worker.postMessage({ type: "initialize", publicKey: this.publicKey }); + } + + private handleWorkerError(worker: Worker, event: ErrorEvent) { + event.preventDefault(); + const job = this.activeJobs.get(worker); + this.activeJobs.delete(worker); + worker.terminate(); + + if (this.stopped) { + return; + } + if (job && job.attempts >= 3) { + this.onError?.(event); + this.terminate(); + return; + } + if (job) { + this.queuedJobs.unshift(job); + } + + const workerIndex = this.workers.indexOf(worker); + if (workerIndex === -1 || !this.createWorker) { + this.onError?.(event); + this.terminate(); + return; + } + + try { + const replacement = this.createWorker(); + this.workers[workerIndex] = replacement; + this.configureWorker(replacement); + this.availableWorkers.push(replacement); + this.dispatch(); + } catch { + this.onError?.(event); + this.terminate(); + } + } + + verify(kind: number, payload: Uint8Array, receivedAt: number) { + if (this.stopped) { + return; + } + this.queuedJobs.push({ + sequence: this.nextJobSequence, + kind, + payload, + receivedAt, + attempts: 0, + }); + this.nextJobSequence += 1; + this.dispatch(); + } + + private dispatch() { + while (this.availableWorkers.length > 0 && this.queuedJobs.length > 0) { + const worker = this.availableWorkers.shift()!; + const job = this.queuedJobs.shift()!; + job.attempts += 1; + this.activeJobs.set(worker, job); + const { sequence, kind, payload, receivedAt } = job; + worker.postMessage({ type: "verify", sequence, kind, payload, receivedAt }); + } + } + + terminate() { + this.stopped = true; + this.queuedJobs.length = 0; + this.availableWorkers.length = 0; + this.completedResults.clear(); + this.activeJobs.clear(); + for (const worker of this.workers) { + worker.terminate(); + } + } +} diff --git a/explorer/src/leaderLocation.test.ts b/explorer/src/leaderLocation.test.ts new file mode 100644 index 00000000..b7e133de --- /dev/null +++ b/explorer/src/leaderLocation.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@jest/globals"; +import { resolveLeaderLocation } from "./leaderLocation"; + +const locations: [[number, number], string][] = [ + [[1, 2], "First"], + [[3, 4], "Second"], +]; + +test("maps the certified block leader to its deployed location", () => { + expect( + resolveLeaderLocation( + new Uint8Array([0xff, 0xab]), + ["01ab", "ffab"], + locations, + ), + ).toEqual({ location: [3, 4], locationName: "Second" }); +}); + +test("does not infer a location when the certified leader is unknown", () => { + expect( + resolveLeaderLocation( + new Uint8Array([0x00, 0xab]), + ["01ab", "ffab"], + locations, + ), + ).toBeUndefined(); +}); diff --git a/explorer/src/leaderLocation.ts b/explorer/src/leaderLocation.ts new file mode 100644 index 00000000..6d03e216 --- /dev/null +++ b/explorer/src/leaderLocation.ts @@ -0,0 +1,27 @@ +import { hexUint8Array } from "./utils"; + +export interface LeaderLocation { + location: [number, number]; + locationName: string; +} + +export const resolveLeaderLocation = ( + leader: Uint8Array | undefined, + participants: string[] | undefined, + locations: [[number, number], string][], +): LeaderLocation | undefined => { + if (!leader || !participants || participants.length !== locations.length) { + return undefined; + } + + const publicKey = hexUint8Array(leader, leader.length * 2); + const index = participants.indexOf(publicKey); + if (index === -1) { + return undefined; + } + + return { + location: locations[index][0], + locationName: locations[index][1], + }; +}; diff --git a/explorer/src/timeline.test.ts b/explorer/src/timeline.test.ts new file mode 100644 index 00000000..a632eae1 --- /dev/null +++ b/explorer/src/timeline.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "@jest/globals"; +import { scaleTimelineWidth } from "./timeline"; + +test("keeps sub-second consensus lifecycles within a one-second timeline", () => { + expect(scaleTimelineWidth(500, 1000)).toBe(500); + expect(scaleTimelineWidth(1000, 1000)).toBe(1000); + expect(scaleTimelineWidth(1500, 1000)).toBe(1000); + expect(scaleTimelineWidth(-1, 1000)).toBe(0); +}); diff --git a/explorer/src/timeline.ts b/explorer/src/timeline.ts new file mode 100644 index 00000000..ea584b36 --- /dev/null +++ b/explorer/src/timeline.ts @@ -0,0 +1,4 @@ +const TIMELINE_DURATION_MS = 1_000; + +export const scaleTimelineWidth = (latency: number, availableWidth: number): number => + Math.min(Math.max(latency, 0) / TIMELINE_DURATION_MS, 1) * availableWidth; diff --git a/explorer/src/types.ts b/explorer/src/types.ts index 5c3714d1..74da4d1e 100644 --- a/explorer/src/types.ts +++ b/explorer/src/types.ts @@ -6,6 +6,7 @@ export type SearchType = 'block' | 'notarization' | 'finalization' | 'seed'; // Block data export interface BlockJs { + leader: Uint8Array; height: number; timestamp: number; digest: Uint8Array; @@ -61,4 +62,4 @@ export type SearchResult = SeedJs | NotarizedJs | FinalizedJs | BlockJs | null; export const MS_PER_SECOND = 1000; export const MS_PER_MINUTE = 60000; export const MS_PER_HOUR = 3600000; -export const MS_PER_DAY = 86400000; \ No newline at end of file +export const MS_PER_DAY = 86400000; diff --git a/indexer/src/main.rs b/indexer/src/main.rs index 4990545c..34b02c27 100644 --- a/indexer/src/main.rs +++ b/indexer/src/main.rs @@ -54,6 +54,8 @@ struct DeployerConfig { struct ExplorerConfig { name: String, description: String, + #[serde(default)] + participants: Vec, locations: Vec<([f64; 2], String)>, } @@ -70,6 +72,8 @@ struct RuntimeExplorerConfig<'a> { public_key_hex: &'a str, #[serde(rename = "LOCATIONS")] locations: &'a [([f64; 2], String)], + #[serde(rename = "PARTICIPANTS")] + participants: &'a [String], name: &'a str, description: &'a str, mode: &'a str, @@ -82,6 +86,7 @@ fn local_explorer_config() -> ExplorerConfig { ExplorerConfig { name: "Local Indexer".to_string(), description: "An Alto indexer running on this machine.".to_string(), + participants: Vec::new(), locations: Vec::new(), } } @@ -118,6 +123,7 @@ fn explorer_script(settings: &Settings) -> Result { let config = RuntimeExplorerConfig { public_key_hex: &settings.identity, locations: &settings.explorer.locations, + participants: &settings.explorer.participants, name: &settings.explorer.name, description: &settings.explorer.description, mode: settings.explorer_mode, @@ -235,6 +241,7 @@ mod tests { explorer: ExplorerConfig { name: "Live Cluster".to_string(), description: "description".to_string(), + participants: vec!["participant".to_string()], locations: vec![([1.0, 2.0], "City".to_string())], }, explorer_mode: "public", @@ -242,6 +249,7 @@ mod tests { let script = explorer_script(&settings).unwrap(); assert!(script.contains(r#""PUBLIC_KEY_HEX":"abcd""#)); + assert!(script.contains(r#""PARTICIPANTS":["participant"]"#)); assert!(script.contains(r#""LOCATIONS":[[[1.0,2.0],"City"]]"#)); assert!(script.contains("window.location.host")); } diff --git a/types/src/wasm.rs b/types/src/wasm.rs index 0da96f9c..ff5fef04 100644 --- a/types/src/wasm.rs +++ b/types/src/wasm.rs @@ -26,6 +26,7 @@ pub struct ProofJs { #[derive(Serialize)] pub struct BlockJs { + pub leader: Vec, pub parent: Vec, pub height: u64, pub timestamp: u64, @@ -84,6 +85,7 @@ pub fn parse_notarized(identity: Vec, bytes: Vec) -> JsValue { signature: certificate.vote_signature.encode().to_vec(), }, block: BlockJs { + leader: notarized.block.context.leader.encode().to_vec(), parent: notarized.block.parent.to_vec(), height: notarized.block.height.get(), timestamp: notarized.block.timestamp, @@ -114,6 +116,7 @@ pub fn parse_finalized(identity: Vec, bytes: Vec) -> JsValue { signature: certificate.vote_signature.encode().to_vec(), }, block: BlockJs { + leader: finalized.block.context.leader.encode().to_vec(), parent: finalized.block.parent.to_vec(), height: finalized.block.height.get(), timestamp: finalized.block.timestamp, @@ -129,6 +132,7 @@ pub fn parse_block(bytes: Vec) -> JsValue { return JsValue::NULL; }; let block_js = BlockJs { + leader: block.context.leader.encode().to_vec(), parent: block.parent.to_vec(), height: block.height.get(), timestamp: block.timestamp, From c9515eeaafee76c1f8b91d72053ff9e70b5ab660 Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Wed, 12 Aug 2026 11:30:33 -0700 Subject: [PATCH 4/9] progress --- chain/src/engine.rs | 86 ++- chain/src/indexer/mod.rs | 2 +- chain/src/indexer/pusher.rs | 8 +- chain/src/lib.rs | 30 +- client/src/consensus.rs | 4 +- client/src/lib.rs | 31 +- deploy.sh | 38 +- deploy/README.md | 62 ++- deploy/docker-bake.hcl | 10 +- deploy/justfile | 11 + deploy/src/main.rs | 87 ++- explorer/README.md | 6 + explorer/src/AboutModal.tsx | 13 +- explorer/src/App.tsx | 17 +- explorer/src/SearchModal.tsx | 17 +- explorer/src/alto_types/alto_types.js | 10 +- explorer/src/alto_types/alto_types_bg.wasm | Bin 247904 -> 249733 bytes explorer/src/config.ts | 2 + explorer/src/consensusWorker.ts | 6 +- explorer/src/consensusWorkerPool.ts | 3 +- explorer/src/global_config.ts | 1 + explorer/src/local_config.ts | 3 +- explorer/src/usa_config.ts | 3 +- follower/README.md | 1 + follower/examples/global.yml | 3 + follower/examples/usa.yml | 3 + follower/src/main.rs | 7 +- follower/src/test_utils.rs | 9 +- indexer/README.md | 6 +- indexer/src/lib.rs | 30 +- indexer/src/main.rs | 40 +- inspector/README.md | 6 +- inspector/src/main.rs | 27 +- types/src/consensus.rs | 588 ++++++++++++++++++++- types/src/lib.rs | 6 +- types/src/wasm.rs | 28 +- validator/src/main.rs | 7 +- 37 files changed, 1071 insertions(+), 140 deletions(-) diff --git a/chain/src/engine.rs b/chain/src/engine.rs index 3a99f49d..4cdb0fc4 100644 --- a/chain/src/engine.rs +++ b/chain/src/engine.rs @@ -14,7 +14,7 @@ use commonware_consensus::{ }, simplex::{ self, - elector::{self, Random, RandomElector, RoundRobin, RoundRobinElector}, + elector::{self, Random, RoundRobin, RoundRobinElector}, Engine as Consensus, }, types::{Epoch, FixedEpocher, Participant, Round, TermLength, ViewDelta}, @@ -64,7 +64,6 @@ const PAGE_CACHE_CAPACITY: NonZero = NZUsize!(8_192); // 32MB const MAX_REPAIR: NonZero = NZUsize!(20); const MAX_PENDING_ACKS: NonZero = NZUsize!(16); const STABLE_LEADER_STALL_TIMEOUT: Duration = Duration::from_secs(12); -const STABLE_LEADER_OPTIMISTIC_VIEWS: ViewDelta = ViewDelta::new(100); /// Adapts the serialized leader policy to Simplex's statically typed elector configuration. #[derive(Clone, Default)] @@ -76,26 +75,63 @@ impl elector::Config for ElectorConfig { fn build(self, participants: &Set) -> Self::Elector { match self.0 { Leader::Rotating { .. } => { - ConfiguredElector::Rotating(elector::Config::::build(Random, participants)) - } - Leader::Stable { term_length, .. } => { - ConfiguredElector::Stable(elector::Config::::build( - RoundRobin::::default().with_term( - TermLength::new(term_length), - STABLE_LEADER_STALL_TIMEOUT, - STABLE_LEADER_OPTIMISTIC_VIEWS, - ), - participants, - )) + ConfiguredElector::Rotating(RotatingElector::new(participants)) } + Leader::Stable { + term_length, + optimistic_views, + .. + } => ConfiguredElector::Stable(elector::Config::::build( + RoundRobin::::default().with_term( + TermLength::new(term_length), + STABLE_LEADER_STALL_TIMEOUT, + ViewDelta::new(optimistic_views), + ), + participants, + )), } } } +#[derive(Clone)] +struct RotatingElector { + participants: u32, +} + +impl RotatingElector { + fn new(participants: &Set) -> Self { + assert!(!participants.is_empty(), "no participants"); + Self { + participants: u32::try_from(participants.len()).expect("participant count fits in u32"), + } + } +} + +impl elector::Elector for RotatingElector { + fn terms(&self) -> elector::Terms { + elector::Terms::rotating() + } + + fn elect( + &self, + round: Round, + certificate: Option<&::Certificate>, + ) -> Participant { + Random::select_leader::( + round, + self.participants, + certificate.map(|certificate| { + Scheme::verified_vrf_seed_signature(certificate) + .expect("rotating leader requires a verified VRF certificate") + }), + ) + } +} + /// Holds the initialized runtime choice behind the single elector type required by Simplex. #[derive(Clone)] enum ConfiguredElector { - Rotating(RandomElector), + Rotating(RotatingElector), Stable(RoundRobinElector), } @@ -272,8 +308,14 @@ where info!(elapsed = ?start.elapsed(), "restored finalized blocks archive"); // Create marshal - let scheme = Scheme::signer(NAMESPACE, cfg.participants, cfg.polynomial, cfg.share) - .expect("failed to create scheme"); + let scheme = Scheme::signer( + cfg.leader.certificate_mode(), + NAMESPACE, + cfg.participants, + cfg.polynomial, + cfg.share, + ) + .expect("failed to create scheme"); let provider = ConstantProvider::new(scheme.clone()); let epocher = FixedEpocher::new(EPOCH_LENGTH); let genesis = Application::genesis(); @@ -514,11 +556,15 @@ mod tests { fn configured_elector_preserves_rotating_and_stable_terms() { let Fixture { schemes, .. } = vrf::fixture::(&mut StdRng::seed_from_u64(0), NAMESPACE, 4); + let schemes: Vec<_> = schemes.into_iter().map(Scheme::from_vrf).collect(); let participants = schemes[0].participants(); let rotating_config = Leader::rotating(NZU64!(7)); assert_eq!(rotating_config.delay_ms(), NZU64!(7)); - assert_eq!(Leader::default(), Leader::stable(NZU64!(10), NZU32!(1_000))); + assert_eq!( + Leader::default(), + Leader::stable(NZU64!(10), NZU32!(1_000), 48) + ); let rotating = elector::Config::::build(ElectorConfig(rotating_config), participants); @@ -526,7 +572,7 @@ mod tests { elector::Elector::terms(&rotating), elector::Terms::rotating() ); - let random = elector::Config::::build(Random, participants); + let random = RotatingElector::new(participants); let certificate_round = Round::new(EPOCH, View::new(1)); let attestations: Vec<_> = schemes .iter() @@ -548,13 +594,13 @@ mod tests { let term_length = NZU32!(9); let stable = elector::Config::::build( - ElectorConfig(Leader::stable(NZU64!(10), term_length)), + ElectorConfig(Leader::stable(NZU64!(10), term_length, 37)), participants, ); let terms = elector::Elector::terms(&stable); assert_eq!(terms.length(), TermLength::new(term_length)); assert_eq!(terms.stall_timeout(), Some(STABLE_LEADER_STALL_TIMEOUT)); - assert_eq!(terms.optimistic_views(), STABLE_LEADER_OPTIMISTIC_VIEWS); + assert_eq!(terms.optimistic_views(), ViewDelta::new(37)); let first = elector::Elector::elect(&stable, Round::new(EPOCH, View::new(1)), None); let last = elector::Elector::elect( diff --git a/chain/src/indexer/mod.rs b/chain/src/indexer/mod.rs index e4afe962..58f26c3b 100644 --- a/chain/src/indexer/mod.rs +++ b/chain/src/indexer/mod.rs @@ -1,7 +1,7 @@ //! Indexer upload integration for the chain engine. //! //! The indexer integration has two cooperating upload paths: -//! - the live path, where `Pusher` uploads seeds and certificate-bearing +//! - the live path, where `Pusher` uploads available seeds and certificate-bearing //! objects as consensus activity happens; //! - the backfiller path, where `Producer` persists finalized block digests and //! `Consumer` retries block uploads from the backfill queue across diff --git a/chain/src/indexer/pusher.rs b/chain/src/indexer/pusher.rs index 28cd98cf..f0b88c9b 100644 --- a/chain/src/indexer/pusher.rs +++ b/chain/src/indexer/pusher.rs @@ -152,7 +152,9 @@ impl Reporter for Pusher { match activity { Activity::Notarization(notarization) => { let view = notarization.view(); - self.spawn_seed_upload("notarized_seed", notarization.seed(), view); + if let Some(seed) = notarization.seed() { + self.spawn_seed_upload("notarized_seed", seed, view); + } self.spawn_certificate_upload( "notarized_block", view, @@ -167,7 +169,9 @@ impl Reporter for Pusher { } Activity::Finalization(finalization) => { let view = finalization.view(); - self.spawn_seed_upload("finalized_seed", finalization.seed(), view); + if let Some(seed) = finalization.seed() { + self.spawn_seed_upload("finalized_seed", seed, view); + } self.spawn_certificate_upload( "finalized_block", view, diff --git a/chain/src/lib.rs b/chain/src/lib.rs index be8ae9c9..3b11549b 100644 --- a/chain/src/lib.rs +++ b/chain/src/lib.rs @@ -1,3 +1,4 @@ +use alto_types::CertificateMode; use commonware_utils::{NZUsize, NZU32, NZU64}; use serde::{Deserialize, Serialize}; use std::{ @@ -18,6 +19,7 @@ pub const DEFAULT_STORAGE_BUFFER_POOL_MAX_PER_CLASS: NonZeroU32 = NZU32!(16_384) pub const DEFAULT_NETWORK_BUFFER_POOL_MAX_PER_CLASS: NonZeroU32 = NZU32!(4_096); const DEFAULT_STABLE_LEADER_DELAY_MS: NonZeroU64 = NZU64!(10); const DEFAULT_STABLE_LEADER_TERM_LENGTH: NonZeroU32 = NZU32!(1_000); +const DEFAULT_STABLE_LEADER_OPTIMISTIC_VIEWS: u64 = 48; fn default_backfiller_max_active() -> NonZeroUsize { DEFAULT_BACKFILLER_MAX_ACTIVE @@ -39,6 +41,10 @@ fn default_network_buffer_pool_max_per_class() -> Option { Some(DEFAULT_NETWORK_BUFFER_POOL_MAX_PER_CLASS) } +const fn default_stable_leader_optimistic_views() -> u64 { + DEFAULT_STABLE_LEADER_OPTIMISTIC_VIEWS +} + /// Leader election policy for the consensus engine. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(tag = "mode", rename_all = "snake_case")] @@ -49,6 +55,7 @@ pub enum Leader { Stable { delay_ms: NonZeroU64, term_length: NonZeroU32, + optimistic_views: u64, }, } @@ -66,6 +73,8 @@ impl<'de> Deserialize<'de> for Leader { Stable { delay_ms: NonZeroU64, term_length: NonZeroU32, + #[serde(default = "default_stable_leader_optimistic_views")] + optimistic_views: u64, }, } @@ -74,7 +83,8 @@ impl<'de> Deserialize<'de> for Leader { Config::Stable { delay_ms, term_length, - } if term_length.get() > 1 => Ok(Self::stable(delay_ms, term_length)), + optimistic_views, + } if term_length.get() > 1 => Ok(Self::stable(delay_ms, term_length, optimistic_views)), Config::Stable { .. } => Err(serde::de::Error::custom( "stable leader term length must be greater than 1", )), @@ -89,7 +99,11 @@ impl Leader { } /// Creates a stable leader configuration. - pub const fn stable(delay_ms: NonZeroU64, term_length: NonZeroU32) -> Self { + pub const fn stable( + delay_ms: NonZeroU64, + term_length: NonZeroU32, + optimistic_views: u64, + ) -> Self { assert!( term_length.get() > 1, "stable leader term length must be greater than 1" @@ -97,6 +111,7 @@ impl Leader { Self::Stable { delay_ms, term_length, + optimistic_views, } } @@ -106,6 +121,14 @@ impl Leader { Self::Rotating { delay_ms } | Self::Stable { delay_ms, .. } => delay_ms, } } + + /// Threshold certificate construction required by this leader policy. + pub const fn certificate_mode(self) -> CertificateMode { + match self { + Self::Rotating { .. } => CertificateMode::Vrf, + Self::Stable { .. } => CertificateMode::Standard, + } + } } impl Default for Leader { @@ -113,6 +136,7 @@ impl Default for Leader { Self::stable( DEFAULT_STABLE_LEADER_DELAY_MS, DEFAULT_STABLE_LEADER_TERM_LENGTH, + DEFAULT_STABLE_LEADER_OPTIMISTIC_VIEWS, ) } } @@ -858,7 +882,7 @@ mod tests { poll_until_height(&context, required_container).await; // Check indexer uploads - assert!(indexer.seed_seen.load(std::sync::atomic::Ordering::Relaxed)); + assert!(!indexer.seed_seen.load(std::sync::atomic::Ordering::Relaxed)); assert!(indexer .notarization_seen .load(std::sync::atomic::Ordering::Relaxed)); diff --git a/client/src/consensus.rs b/client/src/consensus.rs index 234cafae..cdf18489 100644 --- a/client/src/consensus.rs +++ b/client/src/consensus.rs @@ -82,7 +82,7 @@ impl Client { } let bytes = result.bytes().await.map_err(Error::Reqwest)?; let seed = Seed::decode(bytes.as_ref()).map_err(Error::InvalidData)?; - if self.verify && !seed.verify(&self.certificate_verifier) { + if self.verify && !self.certificate_verifier.verify_seed(&seed) { return Err(Error::InvalidSignature); } @@ -278,7 +278,7 @@ impl Client { let result = Seed::decode(data); match result { Ok(seed) => { - if verify && !seed.verify(&certificate_verifier) { + if verify && !certificate_verifier.verify_seed(&seed) { let _ = sender .unbounded_send(Err(Error::InvalidSignature)); return; diff --git a/client/src/lib.rs b/client/src/lib.rs index 165ccf48..e29b0f0f 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1,6 +1,6 @@ //! Interact with an `alto` indexer. -use alto_types::{Identity, Scheme, NAMESPACE}; +use alto_types::{CertificateMode, Identity, Scheme, NAMESPACE}; use commonware_cryptography::sha256::Digest; use commonware_formatting::hex; use commonware_parallel::Strategy; @@ -66,6 +66,7 @@ pub struct ClientBuilder { uri: String, ws_uri: String, identity: Identity, + certificate_mode: CertificateMode, tls_certs: Vec>, strategy: S, verify: bool, @@ -73,6 +74,9 @@ pub struct ClientBuilder { impl ClientBuilder { /// Create a new builder for the given indexer URI. + /// + /// The builder defaults to VRF certificates for compatibility with existing rotating-leader + /// networks. Stable-leader networks must call [`Self::with_certificate_mode`]. pub fn new(uri: &str, identity: Identity, strategy: S) -> Self { let uri = uri.to_string(); let ws_uri = if let Some(rest) = uri.strip_prefix("https://") { @@ -86,12 +90,19 @@ impl ClientBuilder { uri, ws_uri, identity, + certificate_mode: CertificateMode::Vrf, tls_certs: Vec::new(), strategy, verify: true, } } + /// Select the certificate construction used by the target network. + pub fn with_certificate_mode(mut self, certificate_mode: CertificateMode) -> Self { + self.certificate_mode = certificate_mode; + self + } + /// Disable signature verification for all returned data. pub fn with_verification_disabled(mut self) -> Self { self.verify = false; @@ -108,7 +119,8 @@ impl ClientBuilder { /// Build the client. pub fn build(self) -> Client { - let certificate_verifier = Scheme::certificate_verifier(NAMESPACE, self.identity); + let certificate_verifier = + Scheme::certificate_verifier(self.certificate_mode, NAMESPACE, self.identity); // HTTP/2 multiplexes all requests over a single connection, so // DNS is only resolved once on the initial connect. @@ -173,6 +185,9 @@ pub struct Client { impl Client { /// Create a new client for the given indexer URI. /// + /// This constructor verifies VRF certificates. Use [`Self::new_with_certificate_mode`] for a + /// stable-leader network. + /// /// TLS is automatically configured using the system's root certificates. /// For HTTPS/WSS endpoints with certificates signed by trusted CAs, /// no additional configuration is needed. @@ -182,4 +197,16 @@ impl Client { pub fn new(uri: &str, identity: Identity, strategy: S) -> Self { ClientBuilder::new(uri, identity, strategy).build() } + + /// Create a client for a network using the selected certificate construction. + pub fn new_with_certificate_mode( + uri: &str, + identity: Identity, + certificate_mode: CertificateMode, + strategy: S, + ) -> Self { + ClientBuilder::new(uri, identity, strategy) + .with_certificate_mode(certificate_mode) + .build() + } } diff --git a/deploy.sh b/deploy.sh index 8b2a10be..82450a4e 100755 --- a/deploy.sh +++ b/deploy.sh @@ -3,7 +3,7 @@ set -euo pipefail cd "$(dirname "$0")" -for tool in cargo just docker deployer npm wasm-pack; do +for tool in cargo file just docker deployer npm wasm-pack; do if ! command -v "$tool" >/dev/null 2>&1; then echo "missing required command: $tool" >&2 exit 1 @@ -51,27 +51,28 @@ if [ -d assets ]; then rm -rf ./assets fi -# Keep enough of the 16-vCPU machine free for networking, storage, and consensus -# while reserving most of the remaining compute for signature verification. +# C7gd exposes 16 single-threaded Graviton 3 cores. The 8/16 split is the +# deployment tuning point for overlapping network and signature work. cargo run --locked --bin deploy -- generate \ --peers 50 \ --bootstrappers 5 \ - --worker-threads 4 \ + --worker-threads 8 \ --network-buffer-pool-max-per-class 16384 \ --log-level info \ --mailbox-size 16384 \ --deque-size 256 \ --block-size 0 \ - --signature-threads 12 \ + --signature-threads 16 \ --leader-mode stable \ --leader-delay-ms 5 \ --leader-term-length 100000 \ + --leader-optimistic-views 48 \ --output assets \ remote \ --regions us-west-1,us-east-1,eu-west-1,ap-northeast-1,eu-north-1,ap-south-1,sa-east-1,eu-central-1,ap-northeast-2,ap-southeast-2 \ --monitoring-instance-type c8g.4xlarge \ --monitoring-storage-size 100 \ - --instance-type i7i.4xlarge \ + --instance-type c7gd.4xlarge \ --storage-size 25 \ --dashboard deploy/dashboard.json \ --indexer @@ -108,16 +109,27 @@ if [ -z "$derived_network_identity" ] || [ "$derived_network_identity" != "$depl echo "indexer explorer identity does not match the validator network polynomial" >&2 exit 1 fi +derived_certificate_mode="$(sed -n 's/^export const CERTIFICATE_MODE = "\(.*\)" as const;$/\1/p' "$network_key_check_dir/config.ts")" +deployed_certificate_mode="$(sed -n 's/^certificate_mode: //p' assets/indexer.yaml)" +if [ -z "$derived_certificate_mode" ] || [ "$derived_certificate_mode" != "$deployed_certificate_mode" ]; then + echo "indexer explorer certificate mode does not match the validator leader mode" >&2 + exit 1 +fi cleanup_network_key_check trap - EXIT validator_config_count=0 while IFS= read -r validator_config; do validator_config_count=$((validator_config_count + 1)) - if ! grep -qx 'network_buffer_pool_max_per_class: 16384' "assets/${validator_config}"; then - echo "validator config has an unexpected network buffer pool limit: assets/${validator_config}" >&2 - exit 1 - fi + for expected_setting in \ + 'worker_threads: 8' \ + 'network_buffer_pool_max_per_class: 16384' \ + 'signature_threads: 16'; do + if ! grep -qx "$expected_setting" "assets/${validator_config}"; then + echo "validator config is missing '${expected_setting}': assets/${validator_config}" >&2 + exit 1 + fi + done done < <(awk ' $1 == "binary:" { binary = $2; next } $1 == "config:" && binary == "validator" { print $2 } @@ -135,12 +147,16 @@ if [ ! -f explorer/build/index.html ]; then exit 1 fi -just intel-binaries +just graviton-binaries for artifact in validator indexer; do if [ ! -x "assets/$artifact" ]; then echo "build did not create executable assets/$artifact" >&2 exit 1 fi + if ! file "assets/$artifact" | grep -q 'ARM aarch64'; then + echo "build did not create an AArch64 assets/$artifact binary" >&2 + exit 1 + fi done (cd assets && deployer aws create --config config.yaml --concurrency 50) diff --git a/deploy/README.md b/deploy/README.md index 152dd4cd..6cf062ad 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,7 +15,18 @@ _To configure local indexer upload, add `--indexers ':[;:/test/peers.yaml --config=/test/04dc128c6fc22cb93a9eb785c48d4251346eb7b387cd2a66599cc59a3ce47a37.yaml 0b2412d7eb2238b319920504f19b28447c7dbb3c58059c97d22cc0d27ea31e81: cargo run --bin validator -- --peers=/test/peers.yaml --config=/test/0b2412d7eb2238b319920504f19b28447c7dbb3c58059c97d22cc0d27ea31e81.yaml @@ -123,17 +135,25 @@ indexer: https://your-indexer.example.com ./deploy.sh ``` -This deploys 50 validators on `i7i.4xlarge` NVMe instances with 4 Tokio workers and 12 signature -threads, a 5ms proposal target, and 100,000-view stable-leader terms. It builds the explorer into an -indexer on the same instance type, deploys with concurrency 50, and prints the explorer URL when -deployment completes. Validators use prunable finalized archives but retain the full history. +This deploys 50 validators on 16-core `c7gd.4xlarge` Graviton 3 instances with 8 Tokio workers and +16 signature threads, a 5ms proposal target, and 100,000-view stable-leader terms. It builds the +explorer into an indexer on the same instance type, deploys with concurrency 50, and prints the +explorer URL when deployment completes. Validators use prunable finalized archives but retain the +full history. Validator and indexer binaries use the cross-generation Graviton recipe targeting +`neoverse-512tvb`, AWS's recommended compiler target for Graviton 3. -_This configuration consumes ~10MB of disk space per hour per validator (~5 views per second). With 25GB of storage allocated, validators will exhaust available storage in ~3 months._ +The runtime and signature pools deliberately oversubscribe the 16 single-threaded cores. Treat +8/16 as a deployment tuning point and compare CPU saturation and network backlog before reusing it +for another workload. + +_C7gd provides a 950GB ephemeral NVMe instance store, which the deployer mounts at `/home/ubuntu` +for validator data. The 25GB storage setting sizes the gp3 root volume; terminating or replacing an +instance discards its NVMe data._ ##### USA ```bash -cargo run --bin deploy -- generate --peers 50 --bootstrappers 5 --worker-threads 2 --log-level info --mailbox-size 16384 --deque-size 256 --signature-threads 2 --leader-mode stable --leader-delay-ms 10 --leader-term-length 1000 --output assets remote --regions us-east-1,us-east-2,us-west-1,us-west-2 --monitoring-instance-type c8g.4xlarge --monitoring-storage-size 100 --instance-type c8g.large --storage-size 75 --dashboard deploy/dashboard.json +cargo run --bin deploy -- generate --peers 50 --bootstrappers 5 --worker-threads 2 --log-level info --mailbox-size 16384 --deque-size 256 --signature-threads 2 --leader-mode stable --leader-delay-ms 10 --leader-term-length 1000 --leader-optimistic-views 48 --output assets remote --regions us-east-1,us-east-2,us-west-1,us-west-2 --monitoring-instance-type c8g.4xlarge --monitoring-storage-size 100 --instance-type c8g.large --storage-size 75 --dashboard deploy/dashboard.json ``` _This configuration consumes ~30MB of disk space per hour per validator (~13 views per second). With 75GB of storage allocated, validators will exhaust available storage in ~3 months._ @@ -167,8 +187,10 @@ The build platform is an explicit recipe: | Recipe | Output architecture | CPU target | Example instances | | --- | --- | --- | --- | -| `just validator-graviton-binary` | ARM64 | `neoverse-v1` | Graviton 3/4 (`c7g`, `c8g`) | -| `just indexer-graviton-binary` | ARM64 | `neoverse-v1` | Graviton 3/4 (`c7g`, `c8g`) | +| `just validator-graviton-binary` | ARM64 | `neoverse-512tvb` | Graviton 3/4/5 (`c7g`, `c8g`, `c9g`) | +| `just indexer-graviton-binary` | ARM64 | `neoverse-512tvb` | Graviton 3/4/5 (`c7g`, `c8g`, `c9g`) | +| `just validator-graviton4-binary` | ARM64 | `neoverse-v2` | Graviton 4 (`c8g`, `m8g`, `r8g`, `i8g`) | +| `just indexer-graviton4-binary` | ARM64 | `neoverse-v2` | Graviton 4 (`c8g`, `m8g`, `r8g`, `i8g`) | | `just validator-intel-binary` | x86-64 | `emeraldrapids` | Intel I7i | | `just indexer-intel-binary` | x86-64 | `emeraldrapids` | Intel I7i | @@ -183,13 +205,22 @@ _The Intel binary is compiled with `target-cpu=emeraldrapids`._ The builder runs on the local Docker architecture and cross-compiles the binaries, so no `--platform` argument is needed on an ARM64 development machine. -##### Graviton +##### Graviton 3/4/5 Compatibility ```bash just graviton-binaries ``` -_The Graviton binary is compiled with `target-cpu=neoverse-v1`._ +_The cross-generation Graviton binary is compiled with `target-cpu=neoverse-512tvb`._ + +##### Graviton 4 + +```bash +just graviton4-binaries +``` + +_The Graviton 4 binary is compiled with `target-cpu=neoverse-v2`, exposing the Armv9 and SVE2 +features available on Graviton 4 to LLVM. Do not run this binary on earlier Graviton generations._ The grouped recipes write `assets/validator`, `assets/indexer`, and their debug-symbol variants. Run the recipe matching the deployment's instance type last. @@ -228,8 +259,11 @@ _This dashboard is only accessible from the IP used to deploy the infrastructure # Intel I7i just validator-intel-binary -# Graviton +# Graviton 3/4/5 compatibility just validator-graviton-binary + +# Graviton 4 +just validator-graviton4-binary ``` ##### Restart Validator Binary on EC2 Instances diff --git a/deploy/docker-bake.hcl b/deploy/docker-bake.hcl index 852afcd7..b3a32487 100644 --- a/deploy/docker-bake.hcl +++ b/deploy/docker-bake.hcl @@ -20,7 +20,7 @@ target "graviton" { TARGET_TRIPLE = "aarch64-unknown-linux-gnu" TARGET_CARGO_NAME = "AARCH64_UNKNOWN_LINUX_GNU" TARGET_CC_NAME = "aarch64_unknown_linux_gnu" - TARGET_CPU = "neoverse-v1" + TARGET_CPU = "neoverse-512tvb" TARGET_LINKER = "aarch64-linux-gnu-gcc" TARGET_CXX = "aarch64-linux-gnu-g++" TARGET_AR = "aarch64-linux-gnu-ar" @@ -28,6 +28,14 @@ target "graviton" { } } +target "graviton4" { + inherits = ["graviton"] + tags = ["alto-validator-builder:graviton4-${DEFAULT_TAG}"] + args = { + TARGET_CPU = "neoverse-v2" + } +} + target "intel" { context = "." dockerfile = "deploy/Dockerfile" diff --git a/deploy/justfile b/deploy/justfile index beac7a92..1899f722 100644 --- a/deploy/justfile +++ b/deploy/justfile @@ -15,6 +15,17 @@ indexer-graviton-binary: build-graviton-image prepare-docker-cache graviton-binaries: validator-graviton-binary indexer-graviton-binary +build-graviton4-image: + docker buildx bake --progress plain -f deploy/docker-bake.hcl graviton4 + +validator-graviton4-binary: build-graviton4-image prepare-docker-cache + docker run --rm {{source-mounts}} {{cache-env}} -e PACKAGE_NAME=alto-validator -e BINARY_NAME=validator -e OUTPUT_NAME=validator alto-validator-builder:graviton4-local + +indexer-graviton4-binary: build-graviton4-image prepare-docker-cache + docker run --rm {{source-mounts}} {{cache-env}} -e PACKAGE_NAME=alto-indexer -e BINARY_NAME=indexer -e OUTPUT_NAME=indexer alto-validator-builder:graviton4-local + +graviton4-binaries: validator-graviton4-binary indexer-graviton4-binary + build-intel-image: docker buildx bake --progress plain -f deploy/docker-bake.hcl intel diff --git a/deploy/src/main.rs b/deploy/src/main.rs index 93b5cdd5..0194a382 100644 --- a/deploy/src/main.rs +++ b/deploy/src/main.rs @@ -3,7 +3,7 @@ use alto_chain::{ DEFAULT_BLOCKING_THREADS, DEFAULT_NETWORK_BUFFER_POOL_MAX_PER_CLASS, DEFAULT_STORAGE_BUFFER_POOL_MAX_PER_CLASS, }; -use alto_types::NAMESPACE; +use alto_types::{CertificateMode, NAMESPACE}; use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; use commonware_codec::{Decode, DecodeExt, Encode}; use commonware_consensus::simplex::scheme::bls12381_threshold::vrf as bls12381_threshold; @@ -42,7 +42,14 @@ const PORT: u16 = 4545; const STORAGE_CLASS: &str = "gp3"; const DASHBOARD_FILE: &str = "dashboard.json"; -fn leader_args() -> [Arg; 3] { +const fn certificate_mode_name(mode: CertificateMode) -> &'static str { + match mode { + CertificateMode::Standard => "standard", + CertificateMode::Vrf => "vrf", + } +} + +fn leader_args() -> [Arg; 4] { [ Arg::new("leader_mode") .long("leader-mode") @@ -56,6 +63,10 @@ fn leader_args() -> [Arg; 3] { .long("leader-term-length") .required_if_eq("leader_mode", "stable") .value_parser(value_parser!(u32).range(2..)), + Arg::new("leader_optimistic_views") + .long("leader-optimistic-views") + .required_if_eq("leader_mode", "stable") + .value_parser(value_parser!(u64)), ] } @@ -76,6 +87,7 @@ struct ConfiguredIndexer { struct DeployedIndexerConfig { port: u16, identity: String, + certificate_mode: CertificateMode, explorer: DeployedExplorerConfig, } @@ -180,11 +192,15 @@ fn parse_leader(matches: &ArgMatches) -> Result { if matches.get_one::("leader_term_length").is_some() { return Err("rotating leader mode does not accept --leader-term-length"); } + if matches.get_one::("leader_optimistic_views").is_some() { + return Err("rotating leader mode does not accept --leader-optimistic-views"); + } Ok(Leader::rotating(delay_ms)) } "stable" => Ok(Leader::stable( delay_ms, NonZeroU32::new(*matches.get_one::("leader_term_length").unwrap()).unwrap(), + *matches.get_one::("leader_optimistic_views").unwrap(), )), _ => unreachable!("clap validates leader mode"), } @@ -629,8 +645,10 @@ fn generate_local( println!("To start local indexers, run:"); for url in &configured_local_indexers { if let Some(port) = local_indexer_port(url) { - let command = - format!("cargo run --bin indexer -- --port {port} --identity {identity}"); + let certificate_mode = certificate_mode_name(leader.certificate_mode()); + let command = format!( + "cargo run --bin indexer -- --port {port} --identity {identity} --certificate-mode {certificate_mode}" + ); println!("{url}: {command}"); } } @@ -858,6 +876,7 @@ fn generate_remote( DeployedIndexerConfig { port: INDEXER_PORT, identity: hex(&identity.encode()), + certificate_mode: leader.certificate_mode(), explorer: DeployedExplorerConfig { name: "Live Global Cluster".to_string(), description: format!( @@ -975,6 +994,7 @@ fn explorer_local(dir: String, backend_url: String) { fs::read_to_string(&peer_config_path).expect("failed to read peer config"); let peer_config: Config = serde_yaml::from_str(&peer_config_content).expect("failed to parse peer config"); + let certificate_mode = certificate_mode_name(peer_config.leader.certificate_mode()); let polynomial_hex = peer_config.polynomial; let polynomial = from_hex(&polynomial_hex).expect("invalid polynomial"); let polynomial = Sharing::::decode_cfg( @@ -988,9 +1008,11 @@ fn explorer_local(dir: String, backend_url: String) { let config_ts = format!( "export const BACKEND_URL = \"{}\";\n\ export const PUBLIC_KEY_HEX = \"{}\";\n\ + export const CERTIFICATE_MODE = \"{}\" as const;\n\ export const LOCATIONS: [[number, number], string][] = [];", backend_url, hex(&identity.encode()), + certificate_mode, ); // Write config.ts @@ -1036,6 +1058,7 @@ fn explorer_remote(dir: String, backend_url: String) { fs::read_to_string(&peer_config_path).expect("failed to read peer config"); let peer_config: Config = serde_yaml::from_str(&peer_config_content).expect("failed to parse peer config"); + let certificate_mode = certificate_mode_name(peer_config.leader.certificate_mode()); let polynomial_hex = peer_config.polynomial; let polynomial = from_hex(&polynomial_hex).expect("invalid polynomial"); let polynomial = Sharing::::decode_cfg( @@ -1047,9 +1070,11 @@ fn explorer_remote(dir: String, backend_url: String) { let config_ts = format!( "export const BACKEND_URL = \"{}\";\n\ export const PUBLIC_KEY_HEX = \"{}\";\n\ + export const CERTIFICATE_MODE = \"{}\" as const;\n\ export const LOCATIONS: [[number, number], string][] = [\n{}\n];", backend_url, hex(&identity.encode()), + certificate_mode, locations_str ); @@ -1062,10 +1087,11 @@ fn explorer_remote(dir: String, backend_url: String) { #[cfg(test)] mod tests { use super::{ - block_size_arg, leader_args, parse_indexers, parse_leader, select_regional_peers, - ConfiguredIndexer, + block_size_arg, certificate_mode_name, leader_args, parse_indexers, parse_leader, + select_regional_peers, ConfiguredIndexer, }; use alto_chain::Leader; + use alto_types::CertificateMode; use clap::Command; use commonware_utils::{NZU32, NZU64}; @@ -1134,6 +1160,12 @@ signature_threads: 1 assert_eq!(parse_leader(&matches).unwrap(), Leader::rotating(NZU64!(7))); } + #[test] + fn certificate_mode_names_match_leader_modes() { + assert_eq!(certificate_mode_name(CertificateMode::Standard), "standard"); + assert_eq!(certificate_mode_name(CertificateMode::Vrf), "vrf"); + } + #[test] fn parse_stable_leader() { let matches = Command::new("test") @@ -1146,21 +1178,36 @@ signature_threads: 1 "10", "--leader-term-length", "1000", + "--leader-optimistic-views", + "48", ]) .unwrap(); assert_eq!( parse_leader(&matches).unwrap(), - Leader::stable(NZU64!(10), NZU32!(1_000)) + Leader::stable(NZU64!(10), NZU32!(1_000), 48) ); } #[test] - fn stable_leader_requires_delay_and_term_length() { + fn stable_leader_requires_all_settings() { let result = Command::new("test") .args(leader_args()) .try_get_matches_from(["test", "--leader-mode", "stable"]); assert!(result.is_err()); + let result = Command::new("test") + .args(leader_args()) + .try_get_matches_from([ + "test", + "--leader-mode", + "stable", + "--leader-delay-ms", + "10", + "--leader-term-length", + "1000", + ]); + assert!(result.is_err()); + let result = Command::new("test") .args(leader_args()) .try_get_matches_from([ @@ -1171,6 +1218,8 @@ signature_threads: 1 "10", "--leader-term-length", "1", + "--leader-optimistic-views", + "48", ]); assert!(result.is_err()); } @@ -1190,17 +1239,37 @@ signature_threads: 1 ]) .unwrap(); assert!(parse_leader(&matches).is_err()); + + let matches = Command::new("test") + .args(leader_args()) + .try_get_matches_from([ + "test", + "--leader-mode", + "rotating", + "--leader-delay-ms", + "10", + "--leader-optimistic-views", + "48", + ]) + .unwrap(); + assert!(parse_leader(&matches).is_err()); } #[test] fn leader_config_round_trips() { for leader in [ Leader::rotating(NZU64!(7)), - Leader::stable(NZU64!(10), NZU32!(1_000)), + Leader::stable(NZU64!(10), NZU32!(1_000), 48), ] { let encoded = serde_yaml::to_string(&leader).unwrap(); assert_eq!(serde_yaml::from_str::(&encoded).unwrap(), leader); } + + assert_eq!( + serde_yaml::from_str::("mode: stable\ndelay_ms: 10\nterm_length: 1000\n") + .unwrap(), + Leader::stable(NZU64!(10), NZU32!(1_000), 48) + ); } #[test] diff --git a/explorer/README.md b/explorer/README.md index 898e936b..4dcf5593 100644 --- a/explorer/README.md +++ b/explorer/README.md @@ -26,6 +26,9 @@ export const BACKEND_URL = "global.alto.example.com"; // Consensus threshold key (hex-encoded) export const PUBLIC_KEY_HEX = "92b050b6..."; +// Certificate construction: standard for stable leaders, vrf for rotating leaders +export const CERTIFICATE_MODE = "standard" as const; + // Ordered list of validator locations (sorted by validator public key) export const LOCATIONS: [[number, number], string][] = [ [[37.7749, -122.4194], "San Francisco"], @@ -62,6 +65,9 @@ export const BACKEND_URL = "localhost:8080"; // Consensus threshold key (hex-encoded) export const PUBLIC_KEY_HEX = "82f8a77b..."; +// Certificate construction: standard for stable leaders, vrf for rotating leaders +export const CERTIFICATE_MODE = "standard" as const; + // Empty locations array (map will be hidden) export const LOCATIONS: [[number, number], string][] = []; ``` diff --git a/explorer/src/AboutModal.tsx b/explorer/src/AboutModal.tsx index 32c5da17..72976f7d 100644 --- a/explorer/src/AboutModal.tsx +++ b/explorer/src/AboutModal.tsx @@ -4,9 +4,10 @@ import { MODE } from './config'; interface AboutModalProps { isOpen: boolean; onClose: () => void; + standardCertificates: boolean; } -const AboutModal: React.FC = ({ isOpen, onClose }) => { +const AboutModal: React.FC = ({ isOpen, onClose, standardCertificates }) => { // Add effect to handle link targets useEffect(() => { if (isOpen) { @@ -68,7 +69,7 @@ const AboutModal: React.FC = ({ isOpen, onClose }) => {

Validators enter a new view whenever they observe either 2f+1 votes for a block proposal or 2f+1 nullifies - (to skip this view) AND some seed (a VRF used to select the next leader). Validators finalize a view whenever they + (to skip this view){standardCertificates ? '.' : ' and a VRF seed used to select the next leader.'} Validators finalize a view whenever they observe 2f+1 finalizes for a block proposal.

@@ -78,11 +79,13 @@ const AboutModal: React.FC = ({ isOpen, onClose }) => {

  • - Seeded + {standardCertificates ? 'Proposed' : 'Seeded'}
    - Some leader has been elected to propose a block. + {standardCertificates + ? 'The stable round-robin leader has proposed a block.' + : 'Some leader has been elected to propose a block.'} {MODE === 'public' && ' The dot on the map (of the same color) is the region where the leader is located.'} - {' '}A new leader is elected for each view. + {!standardCertificates && ' A new leader is elected for each view.'}
  • diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index dbd7357c..941ab838 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -117,6 +117,7 @@ const App: React.FC = () => { const clusterConfig = useMemo(() => getClusterConfig(selectedCluster), [selectedCluster]); const allConfigs = useMemo(() => getClusters(), []); const { BACKEND_URL, PUBLIC_KEY_HEX, LOCATIONS, PARTICIPANTS } = clusterConfig; + const standardCertificates = clusterConfig.CERTIFICATE_MODE === 'standard'; const PUBLIC_KEY = useMemo(() => hexToUint8Array(PUBLIC_KEY_HEX), [PUBLIC_KEY_HEX]); const [views, setViews] = useState([]); @@ -426,6 +427,10 @@ const App: React.FC = () => { const view = notarized.proof.view; const leaderLocation = resolveLeaderLocation(notarized.block?.leader, PARTICIPANTS, LOCATIONS); setViews((prevViews) => { + const lastObservedView = lastObservedViewRef.current; + if (lastObservedView === null || view > lastObservedView) { + lastObservedViewRef.current = view; + } const index = prevViews.findIndex((v) => v.view === view); // If the view exists and is already finalized, ignore this notarization completely @@ -506,6 +511,10 @@ const App: React.FC = () => { const view = finalized.proof.view; const leaderLocation = resolveLeaderLocation(finalized.block?.leader, PARTICIPANTS, LOCATIONS); setViews((prevViews) => { + const lastObservedView = lastObservedViewRef.current; + if (lastObservedView === null || view > lastObservedView) { + lastObservedViewRef.current = view; + } const index = prevViews.findIndex((v) => v.view === view); let newViews = [...prevViews]; const currentTime = adjustTime(receivedAt); @@ -762,6 +771,7 @@ const App: React.FC = () => { setShowError(true); }, createWorker, + standardCertificates, ); connectWebSocket(); }; @@ -801,7 +811,7 @@ const App: React.FC = () => { } } }; - }, [isLoading, isInMaintenance, BACKEND_URL, PUBLIC_KEY]); + }, [isLoading, isInMaintenance, BACKEND_URL, PUBLIC_KEY, standardCertificates]); // Loading state - show nothing until we get the result of the health check if (isLoading) { @@ -931,7 +941,7 @@ const App: React.FC = () => {

    Timeline

    - +
    @@ -962,6 +972,7 @@ const App: React.FC = () => { setIsAboutModalOpen(false)} + standardCertificates={standardCertificates} /> = ({ viewData, currentTime, isMobile }) => { {/* Finalized state with notarization */} {status === "finalized" && !renderFinalizedWithoutNotarization && ( <> - {/* Base segment (seed to notarization) */} + {/* Base segment (proposal or seed to notarization) */}
    = ({ isOpen, onClose, clusterConfi const [lastSearchType, setLastSearchType] = useState(null); const [showHelp, setShowHelp] = useState(false); const [wasmInitialized, setWasmInitialized] = useState(false); + const standardCertificates = clusterConfig.CERTIFICATE_MODE === 'standard'; + + useEffect(() => { + if (standardCertificates && searchType === 'seed') { + setSearchType('finalization'); + } + }, [searchType, standardCertificates]); useEffect(() => { const initWasm = async () => { @@ -178,16 +185,16 @@ const SearchModal: React.FC = ({ isOpen, onClose, clusterConfi if (!result) throw new Error("Failed to parse seed data"); return result; } else if (searchType === 'notarization') { - const result = parse_notarized(PUBLIC_KEY, data); + const result = parse_notarized(PUBLIC_KEY, data, standardCertificates); if (!result) throw new Error("Failed to parse notarization data"); return result; } else if (searchType === 'finalization') { - const result = parse_finalized(PUBLIC_KEY, data); + const result = parse_finalized(PUBLIC_KEY, data, standardCertificates); if (!result) throw new Error("Failed to parse finalization data"); return result; } else if (searchType === 'block') { if (query === 'latest' || typeof query === 'number') { - const result = parse_finalized(PUBLIC_KEY, data); + const result = parse_finalized(PUBLIC_KEY, data, standardCertificates); if (!result) throw new Error("Failed to parse block data"); return result; } else { @@ -325,7 +332,7 @@ const SearchModal: React.FC = ({ isOpen, onClose, clusterConfi
  • Block: by height (number), digest (hex), or "latest"
  • Notarization: by view number or "latest"
  • Finalization: by view number or "latest"
  • -
  • Seed: by view number or "latest"
  • + {!standardCertificates &&
  • Seed: by view number or "latest"
  • }

    You can also search for ranges (e.g., "10..20") to get multiple results.

    @@ -340,7 +347,7 @@ const SearchModal: React.FC = ({ isOpen, onClose, clusterConfi value={searchType} onChange={(e) => setSearchType(e.target.value as SearchType)} > - + {!standardCertificates && } diff --git a/explorer/src/alto_types/alto_types.js b/explorer/src/alto_types/alto_types.js index ae99b1f5..43cc319c 100644 --- a/explorer/src/alto_types/alto_types.js +++ b/explorer/src/alto_types/alto_types.js @@ -24,28 +24,30 @@ export function parse_block(bytes) { /** * @param {Uint8Array} identity * @param {Uint8Array} bytes + * @param {boolean} standard * @returns {any} */ -export function parse_finalized(identity, bytes) { +export function parse_finalized(identity, bytes, standard) { const ptr0 = passArray8ToWasm0(identity, wasm.__wbindgen_malloc); const len0 = WASM_VECTOR_LEN; const ptr1 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc); const len1 = WASM_VECTOR_LEN; - const ret = wasm.parse_finalized(ptr0, len0, ptr1, len1); + const ret = wasm.parse_finalized(ptr0, len0, ptr1, len1, standard); return ret; } /** * @param {Uint8Array} identity * @param {Uint8Array} bytes + * @param {boolean} standard * @returns {any} */ -export function parse_notarized(identity, bytes) { +export function parse_notarized(identity, bytes, standard) { const ptr0 = passArray8ToWasm0(identity, wasm.__wbindgen_malloc); const len0 = WASM_VECTOR_LEN; const ptr1 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc); const len1 = WASM_VECTOR_LEN; - const ret = wasm.parse_notarized(ptr0, len0, ptr1, len1); + const ret = wasm.parse_notarized(ptr0, len0, ptr1, len1, standard); return ret; } diff --git a/explorer/src/alto_types/alto_types_bg.wasm b/explorer/src/alto_types/alto_types_bg.wasm index 18cd0b0d96e662855d9129cd20e18f683465a324..132bd2789fe2d872e3ce13a7723c8b4505685ea3 100644 GIT binary patch delta 54548 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 OrU@?GW?FifZu}4B%Tdt) delta 52867 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 mX#9&3)#zyATX<1mr{ diff --git a/explorer/src/config.ts b/explorer/src/config.ts index 7a1d37df..c4ad06d9 100644 --- a/explorer/src/config.ts +++ b/explorer/src/config.ts @@ -4,10 +4,12 @@ import * as localConfig from './local_config'; export type Cluster = 'global' | 'usa' | 'local'; export type Mode = 'public' | 'local'; +export type CertificateMode = 'standard' | 'vrf'; export interface ClusterConfig { BACKEND_URL: string; PUBLIC_KEY_HEX: string; + CERTIFICATE_MODE: CertificateMode; LOCATIONS: [[number, number], string][]; PARTICIPANTS?: string[]; name: string; diff --git a/explorer/src/consensusWorker.ts b/explorer/src/consensusWorker.ts index b9384f30..6912f4f1 100644 --- a/explorer/src/consensusWorker.ts +++ b/explorer/src/consensusWorker.ts @@ -7,12 +7,14 @@ import init, { } from "./alto_types/alto_types.js"; let publicKey: Uint8Array | undefined; +let standard = false; const initialized = init(); const worker = globalThis as unknown as DedicatedWorkerGlobalScope; worker.onmessage = async (event: MessageEvent) => { if (event.data.type === "initialize") { publicKey = event.data.publicKey; + standard = event.data.standard; return; } if (event.data.type !== "verify") { @@ -31,10 +33,10 @@ worker.onmessage = async (event: MessageEvent) => { artifact = parse_seed(publicKey, payload); break; case 1: - artifact = parse_notarized(publicKey, payload); + artifact = parse_notarized(publicKey, payload, standard); break; case 2: - artifact = parse_finalized(publicKey, payload); + artifact = parse_finalized(publicKey, payload, standard); break; } diff --git a/explorer/src/consensusWorkerPool.ts b/explorer/src/consensusWorkerPool.ts index 555a9fb0..404489e3 100644 --- a/explorer/src/consensusWorkerPool.ts +++ b/explorer/src/consensusWorkerPool.ts @@ -38,6 +38,7 @@ export class ConsensusWorkerPool { private readonly onVerified: (result: VerifiedConsensusArtifact) => void, private readonly onError?: (event: ErrorEvent) => void, private readonly createWorker?: () => Worker, + private readonly standard = false, ) { this.availableWorkers = []; for (const worker of workers) { @@ -67,7 +68,7 @@ export class ConsensusWorkerPool { this.dispatch(); }; worker.onerror = (event) => this.handleWorkerError(worker, event); - worker.postMessage({ type: "initialize", publicKey: this.publicKey }); + worker.postMessage({ type: "initialize", publicKey: this.publicKey, standard: this.standard }); } private handleWorkerError(worker: Worker, event: ErrorEvent) { diff --git a/explorer/src/global_config.ts b/explorer/src/global_config.ts index 516a803a..3c019077 100644 --- a/explorer/src/global_config.ts +++ b/explorer/src/global_config.ts @@ -1,5 +1,6 @@ export const BACKEND_URL = "global.alto.exoware.xyz"; export const PUBLIC_KEY_HEX = "83a93d74819bc17b3f53258216b54ecba1a13e1257a0514241bdd79c7757b7c3921451021e7a592d12296076ef58a8b600179a2d006e8d73c337f2f3578a245c286f4af8921e4e4fca0b3375842c81cccd2b4ce7e875a5591fdded49c93a905c"; +export const CERTIFICATE_MODE = "vrf" as const; export const LOCATIONS: [[number, number], string][] = [ [[37.7749, -122.4194], "San Francisco"], [[38.8339, -77.3074], "Ashburn"], diff --git a/explorer/src/local_config.ts b/explorer/src/local_config.ts index 710a8a8d..cd94a104 100644 --- a/explorer/src/local_config.ts +++ b/explorer/src/local_config.ts @@ -1,3 +1,4 @@ export const BACKEND_URL = "localhost:8080"; export const PUBLIC_KEY_HEX = "82f8a77b5641783b5a5c7d8f89b9904f8145b62e24fac61d2a6d5320a7fc857a0dcb19c26403dc9121c88c01932a52cb0b08a0f3d2b98c6f5adffc36f2007b9fd677f5564883510634c890b8714a05eff4240e59ef1562339d71c1c13ae4c34f"; -export const LOCATIONS: [[number, number], string][] = []; \ No newline at end of file +export const CERTIFICATE_MODE = "vrf" as const; +export const LOCATIONS: [[number, number], string][] = []; diff --git a/explorer/src/usa_config.ts b/explorer/src/usa_config.ts index 72c48d32..ec19980e 100644 --- a/explorer/src/usa_config.ts +++ b/explorer/src/usa_config.ts @@ -1,5 +1,6 @@ export const BACKEND_URL = "usa.alto.exoware.xyz"; export const PUBLIC_KEY_HEX = "a156881f2d99ecd30cc7b9550d65765482cf04be7ec740b8ae64abb3ad8630a61619319c3d4abba4b1791bf774424a7f096cf35654524187cafd338f7c89523c68676a80c941ed40c7de045869fb00326df1eadb26b62eb2096318814ee9d080"; +export const CERTIFICATE_MODE = "vrf" as const; export const LOCATIONS: [[number, number], string][] = [ [[38.8339, -77.3074], "Ashburn"], [[40.0946, -82.7541], "Columbus"], @@ -51,4 +52,4 @@ export const LOCATIONS: [[number, number], string][] = [ [[45.9175, -119.2684], "Boardman"], [[38.8339, -77.3074], "Ashburn"], [[40.0946, -82.7541], "Columbus"] -]; \ No newline at end of file +]; diff --git a/follower/README.md b/follower/README.md index f686165a..56ab3e22 100644 --- a/follower/README.md +++ b/follower/README.md @@ -37,6 +37,7 @@ _To deploy your own instance of `alto`, read the guide in [deploy](../deploy/REA |-------|-------------| | `source` | URL of the indexer to fetch blocks and stream certificates from | | `identity` | Hex-encoded BLS12-381 threshold public key used to verify consensus signatures | +| `certificate_mode` | Required certificate construction used by the source: `standard` for stable leaders or `vrf` for rotating leaders | | `directory` | Path to store finalized blocks and state | | `worker_threads` | Number of runtime worker threads | | `signature_threads` | Number of threads for signature verification | diff --git a/follower/examples/global.yml b/follower/examples/global.yml index a98b0291..00cee08a 100644 --- a/follower/examples/global.yml +++ b/follower/examples/global.yml @@ -10,6 +10,9 @@ source: "https://global.alto.exoware.xyz" # This is the threshold public key used to verify finalization signatures identity: "83a93d74819bc17b3f53258216b54ecba1a13e1257a0514241bdd79c7757b7c3921451021e7a592d12296076ef58a8b600179a2d006e8d73c337f2f3578a245c286f4af8921e4e4fca0b3375842c81cccd2b4ce7e875a5591fdded49c93a905c" +# Certificate construction used by this rotating-leader network +certificate_mode: "vrf" + # Directory for storing finalized blocks and state directory: "/tmp/alto-follower-global" diff --git a/follower/examples/usa.yml b/follower/examples/usa.yml index 5d54a224..c76f1fef 100644 --- a/follower/examples/usa.yml +++ b/follower/examples/usa.yml @@ -12,6 +12,9 @@ source: "https://usa.alto.exoware.xyz" # This is the threshold public key used to verify finalization signatures identity: "a156881f2d99ecd30cc7b9550d65765482cf04be7ec740b8ae64abb3ad8630a61619319c3d4abba4b1791bf774424a7f096cf35654524187cafd338f7c89523c68676a80c941ed40c7de045869fb00326df1eadb26b62eb2096318814ee9d080" +# Certificate construction used by this rotating-leader network +certificate_mode: "vrf" + # Directory for storing finalized blocks and state directory: "/tmp/alto-follower-usa" diff --git a/follower/src/main.rs b/follower/src/main.rs index 10cdfb98..9c398b26 100644 --- a/follower/src/main.rs +++ b/follower/src/main.rs @@ -1,6 +1,6 @@ use alto_client::consensus::{Message, Payload}; use alto_client::{ClientBuilder, IndexQuery, Query}; -use alto_types::{Finalized, Identity, Notarized, Scheme, NAMESPACE}; +use alto_types::{CertificateMode, Finalized, Identity, Notarized, Scheme, NAMESPACE}; use clap::{Arg, Command}; use commonware_codec::DecodeExt; use commonware_consensus::types::Height; @@ -38,6 +38,7 @@ mod test_utils; pub struct Config { pub source: String, pub identity: String, + pub certificate_mode: CertificateMode, pub directory: String, pub worker_threads: NonZero, pub signature_threads: NonZero, @@ -183,8 +184,10 @@ fn main() { // // Any certificate verification failure is treated as fatal and // intentionally crashes the follower (fail-fast). - let scheme = Scheme::certificate_verifier(NAMESPACE, identity); + let scheme = + Scheme::certificate_verifier(config.certificate_mode, NAMESPACE, identity); let client = ClientBuilder::new(&config.source, identity, Sequential) + .with_certificate_mode(config.certificate_mode) .with_verification_disabled() .build(); diff --git a/follower/src/test_utils.rs b/follower/src/test_utils.rs index b7ac110a..59be3787 100644 --- a/follower/src/test_utils.rs +++ b/follower/src/test_utils.rs @@ -3,7 +3,7 @@ use crate::Source; use alto_client::consensus::{Message, Payload}; use alto_client::{IndexQuery, Query}; -use alto_types::{Block, Context, Finalized, Notarized, Scheme, EPOCH, NAMESPACE}; +use alto_types::{Block, CertificateMode, Context, Finalized, Notarized, Scheme, EPOCH, NAMESPACE}; use bytes::Bytes; use commonware_consensus::{ simplex::{ @@ -110,6 +110,7 @@ impl TestFixture { let mut rng = StdRng::seed_from_u64(0); let Fixture { schemes, .. } = bls12381_threshold::fixture::(&mut rng, NAMESPACE, 4); + let schemes = schemes.into_iter().map(Scheme::from_vrf).collect(); Self { schemes } } @@ -166,8 +167,8 @@ impl TestFixture { } pub fn verifier_scheme(&self) -> Scheme { - let identity = *self.schemes[0].polynomial().public(); - Scheme::certificate_verifier(NAMESPACE, identity) + let identity = *self.schemes[0].identity(); + Scheme::certificate_verifier(CertificateMode::Vrf, NAMESPACE, identity) } pub fn wrong_verifier_scheme(&self) -> Scheme { @@ -175,6 +176,6 @@ impl TestFixture { let Fixture { schemes, .. } = bls12381_threshold::fixture::(&mut rng, NAMESPACE, 4); let wrong_identity = *schemes[0].polynomial().public(); - Scheme::certificate_verifier(NAMESPACE, wrong_identity) + Scheme::certificate_verifier(CertificateMode::Vrf, NAMESPACE, wrong_identity) } } diff --git a/indexer/README.md b/indexer/README.md index 02446b3b..97f5d5c0 100644 --- a/indexer/README.md +++ b/indexer/README.md @@ -30,10 +30,12 @@ cargo install alto-indexer ### Start the indexer ```bash -indexer --port 8080 --identity +indexer --port 8080 --identity --certificate-mode ``` -The identity is the threshold public key of the consensus network. It is used to verify incoming consensus artifacts. +The identity is the threshold public key of the consensus network. It is used to verify incoming +consensus artifacts. Use `standard` for a stable-leader network and `vrf` for a rotating-leader +network. ## API Endpoints diff --git a/indexer/src/lib.rs b/indexer/src/lib.rs index dcc3be3f..ec2cb36b 100644 --- a/indexer/src/lib.rs +++ b/indexer/src/lib.rs @@ -53,7 +53,7 @@ impl Indexer { pub fn submit_seed(&self, seed: Seed) -> Result<(), &'static str> { // Verify signature with identity - if !seed.verify(&self.scheme) { + if !self.scheme.verify_seed(&seed) { return Err("Invalid seed signature"); } @@ -410,10 +410,7 @@ mod tests { impl TestContext { /// Create a new test context with a running server and client. async fn new() -> Self { - let mut rng = StdRng::seed_from_u64(0); - let Fixture { schemes, .. } = - bls12381_threshold::fixture::(&mut rng, NAMESPACE, 4); - let identity = *schemes[0].polynomial().public(); + let (schemes, identity) = fixture(0); let (addr, _) = start_server(schemes[0].clone(), Sequential).await; let client = Client::new(&format!("http://{addr}"), identity, Sequential); @@ -451,7 +448,7 @@ mod tests { fn seed(&self) -> Seed { let block = self.test_block(); let proposal = self.proposal(&block); - create_notarization(&self.schemes, proposal).seed() + create_notarization(&self.schemes, proposal).seed().unwrap() } /// Create a notarized block. @@ -523,6 +520,7 @@ mod tests { let Fixture { schemes, .. } = bls12381_threshold::fixture::(&mut rng, NAMESPACE, 4); let identity = *schemes[0].polynomial().public(); + let schemes = schemes.into_iter().map(Scheme::from_vrf).collect(); (schemes, identity) } @@ -691,7 +689,7 @@ mod tests { View::new(0), block.digest(), ); - let seed = create_notarization(&schemes1, proposal).seed(); + let seed = create_notarization(&schemes1, proposal).seed().unwrap(); // Server accepts it (signed by schemes1, which server uses) client.seed_upload(seed).await.unwrap(); @@ -711,7 +709,9 @@ mod tests { // Create a seed with wrong schemes let block = ctx.test_block(); let proposal = ctx.proposal(&block); - let bad_seed = create_notarization(&wrong_schemes, proposal).seed(); + let bad_seed = create_notarization(&wrong_schemes, proposal) + .seed() + .unwrap(); // Server rejects it (signature doesn't match server's identity) let result = ctx.client.seed_upload(bad_seed).await; @@ -792,10 +792,7 @@ mod tests { async fn test_tls_https_connection() { let cert_key = generate_self_signed_cert(); - let mut rng = StdRng::seed_from_u64(0); - let Fixture { schemes, .. } = - bls12381_threshold::fixture::(&mut rng, NAMESPACE, 4); - let identity = *schemes[0].polynomial().public(); + let (schemes, identity) = fixture(0); let (addr, handle) = start_tls_server(schemes[0].clone(), &cert_key, Sequential).await; let client = create_tls_client(addr, identity, &cert_key); @@ -819,7 +816,7 @@ mod tests { View::new(0), block.digest(), ); - let seed = create_notarization(&schemes, proposal).seed(); + let seed = create_notarization(&schemes, proposal).seed().unwrap(); // Test HTTPS POST client.seed_upload(seed.clone()).await.unwrap(); @@ -835,10 +832,7 @@ mod tests { async fn test_tls_websocket_connection() { let cert_key = generate_self_signed_cert(); - let mut rng = StdRng::seed_from_u64(0); - let Fixture { schemes, .. } = - bls12381_threshold::fixture::(&mut rng, NAMESPACE, 4); - let identity = *schemes[0].polynomial().public(); + let (schemes, identity) = fixture(0); let (addr, handle) = start_tls_server(schemes[0].clone(), &cert_key, Sequential).await; let client = create_tls_client(addr, identity, &cert_key); @@ -862,7 +856,7 @@ mod tests { View::new(0), block.digest(), ); - let seed = create_notarization(&schemes, proposal).seed(); + let seed = create_notarization(&schemes, proposal).seed().unwrap(); // Connect to WebSocket over TLS let mut stream = client.listen().await.unwrap(); diff --git a/indexer/src/main.rs b/indexer/src/main.rs index 34b02c27..aa723587 100644 --- a/indexer/src/main.rs +++ b/indexer/src/main.rs @@ -1,5 +1,5 @@ use alto_indexer::{Api, Indexer}; -use alto_types::{Identity, Scheme, NAMESPACE}; +use alto_types::{CertificateMode, Identity, Scheme, NAMESPACE}; use axum::{ body::{Body, Bytes}, extract::Extension, @@ -34,6 +34,15 @@ struct Args { )] identity: Option, + /// Threshold certificate construction used by the network. + #[clap( + long, + required_unless_present = "config", + conflicts_with = "config", + value_parser = ["standard", "vrf"] + )] + certificate_mode: Option, + /// Path to the deployer-generated hosts file. #[clap(long, requires = "config", conflicts_with = "identity")] hosts: Option, @@ -47,6 +56,7 @@ struct Args { struct DeployerConfig { port: u16, identity: String, + certificate_mode: CertificateMode, explorer: Option, } @@ -62,6 +72,7 @@ struct ExplorerConfig { struct Settings { port: u16, identity: String, + certificate_mode: CertificateMode, explorer: ExplorerConfig, explorer_mode: &'static str, } @@ -74,6 +85,8 @@ struct RuntimeExplorerConfig<'a> { locations: &'a [([f64; 2], String)], #[serde(rename = "PARTICIPANTS")] participants: &'a [String], + #[serde(rename = "CERTIFICATE_MODE")] + certificate_mode: CertificateMode, name: &'a str, description: &'a str, mode: &'a str, @@ -104,6 +117,7 @@ fn load_settings(args: Args) -> Result> { return Ok(Settings { port: config.port, identity: config.identity, + certificate_mode: config.certificate_mode, explorer: config.explorer.unwrap_or_else(local_explorer_config), explorer_mode, }); @@ -114,6 +128,12 @@ fn load_settings(args: Args) -> Result> { identity: args .identity .expect("clap requires --identity when --config is absent"), + certificate_mode: match args.certificate_mode.as_deref() { + Some("standard") => CertificateMode::Standard, + Some("vrf") => CertificateMode::Vrf, + None => unreachable!("clap requires --certificate-mode in direct mode"), + Some(_) => unreachable!("clap validates certificate mode"), + }, explorer: local_explorer_config(), explorer_mode: "local", }) @@ -124,6 +144,7 @@ fn explorer_script(settings: &Settings) -> Result { public_key_hex: &settings.identity, locations: &settings.explorer.locations, participants: &settings.explorer.participants, + certificate_mode: settings.certificate_mode, name: &settings.explorer.name, description: &settings.explorer.description, mode: settings.explorer_mode, @@ -192,7 +213,8 @@ async fn main() -> Result<(), Box> { Identity::decode(&mut bytes.as_slice()).map_err(|_| "Failed to decode identity")?; // Initialize indexer - let certificate_verifier = Scheme::certificate_verifier(NAMESPACE, identity); + let certificate_verifier = + Scheme::certificate_verifier(settings.certificate_mode, NAMESPACE, identity); let indexer = Arc::new(Indexer::new(certificate_verifier, Sequential)); let app = Api::new(indexer) .router() @@ -216,12 +238,19 @@ async fn main() -> Result<(), Box> { #[cfg(test)] mod tests { - use super::{explorer_script, Args, ExplorerConfig, Settings}; + use super::{explorer_script, Args, CertificateMode, ExplorerConfig, Settings}; use clap::Parser; #[test] fn accepts_direct_and_deployer_modes() { - assert!(Args::try_parse_from(["indexer", "--identity", "abcd"]).is_ok()); + assert!(Args::try_parse_from([ + "indexer", + "--identity", + "abcd", + "--certificate-mode", + "standard", + ]) + .is_ok()); assert!(Args::try_parse_from([ "indexer", "--hosts", @@ -230,6 +259,7 @@ mod tests { "config.yaml", ]) .is_ok()); + assert!(Args::try_parse_from(["indexer", "--identity", "abcd"]).is_err()); assert!(Args::try_parse_from(["indexer"]).is_err()); } @@ -238,6 +268,7 @@ mod tests { let settings = Settings { port: 8080, identity: "abcd".to_string(), + certificate_mode: CertificateMode::Standard, explorer: ExplorerConfig { name: "Live Cluster".to_string(), description: "description".to_string(), @@ -250,6 +281,7 @@ mod tests { let script = explorer_script(&settings).unwrap(); assert!(script.contains(r#""PUBLIC_KEY_HEX":"abcd""#)); assert!(script.contains(r#""PARTICIPANTS":["participant"]"#)); + assert!(script.contains(r#""CERTIFICATE_MODE":"standard""#)); assert!(script.contains(r#""LOCATIONS":[[[1.0,2.0],"City"]]"#)); assert!(script.contains("window.location.host")); } diff --git a/inspector/README.md b/inspector/README.md index 28757326..91a5aac4 100644 --- a/inspector/README.md +++ b/inspector/README.md @@ -27,6 +27,10 @@ cargo install alto-inspector _Use `-v` or `--verbose` to enable verbose logging (like request latency). Use `--prepare` to initialize the connection before making the request (for accurate latency measurement)._ +_Use `--certificate-mode standard` when inspecting a stable-leader network. The default is `vrf` +for compatibility with existing rotating-leader networks; stable networks do not publish seed +artifacts._ + ### Get the latest seed ```bash @@ -79,4 +83,4 @@ inspector -- get block 0x65016ff40e824e21fffe903953c07b6d604dbcf39f681c62e7b3ed5 ```bash inspector listen -``` \ No newline at end of file +``` diff --git a/inspector/src/main.rs b/inspector/src/main.rs index 872ac5a0..e6e52a5e 100644 --- a/inspector/src/main.rs +++ b/inspector/src/main.rs @@ -20,7 +20,7 @@ //! //! # Usage //! -//! _Use `-v` or `--verbose` to enable verbose logging (like request latency). Use `--prepare` to initialize the connection before making the request (for accurate latency measurement)._ +//! _Use `-v` or `--verbose` to enable verbose logging (like request latency). Use `--prepare` to initialize the connection before making the request (for accurate latency measurement). Use `--certificate-mode standard` for a stable-leader network; the default is `vrf`._ //! //! ## Get the latest seed //! @@ -80,7 +80,7 @@ use alto_client::{ consensus::{Message, Payload}, Client, IndexQuery, Query, }; -use alto_types::Identity; +use alto_types::{CertificateMode, Identity}; use clap::{value_parser, Arg, Command}; use commonware_codec::DecodeExt; use commonware_formatting::from_hex; @@ -112,6 +112,14 @@ async fn main() { .global(true) .action(clap::ArgAction::SetTrue), ) + .arg( + Arg::new("certificate_mode") + .long("certificate-mode") + .value_parser(["standard", "vrf"]) + .default_value("vrf") + .global(true) + .help("Threshold certificate construction used by the network"), + ) .subcommand( Command::new("listen") .about("Listen for consensus messages") @@ -175,13 +183,23 @@ async fn main() { Level::INFO }; tracing_subscriber::fmt().with_max_level(log_level).init(); + let certificate_mode = match matches + .get_one::("certificate_mode") + .expect("certificate mode has a default") + .as_str() + { + "standard" => CertificateMode::Standard, + "vrf" => CertificateMode::Vrf, + _ => unreachable!("clap validates certificate mode"), + }; if let Some(matches) = matches.subcommand_matches("listen") { let indexer = matches.get_one::("indexer").unwrap(); let identity = matches.get_one::("identity").unwrap(); let identity = from_hex(identity).expect("Failed to decode identity"); let identity = Identity::decode(identity.as_ref()).expect("Invalid identity"); - let client = Client::new(indexer, identity, Sequential); + let client = + Client::new_with_certificate_mode(indexer, identity, certificate_mode, Sequential); let mut stream = client.listen().await.expect("Failed to connect to indexer"); info!("listening for consensus messages..."); @@ -200,7 +218,8 @@ async fn main() { let identity = matches.get_one::("identity").unwrap(); let identity = from_hex(identity).expect("Failed to decode identity"); let identity = Identity::decode(identity.as_ref()).expect("Invalid identity"); - let client = Client::new(indexer, identity, Sequential); + let client = + Client::new_with_certificate_mode(indexer, identity, certificate_mode, Sequential); let prepare_flag = matches.get_flag("prepare"); if prepare_flag { diff --git a/types/src/consensus.rs b/types/src/consensus.rs index 4b39a0a0..dd35b417 100644 --- a/types/src/consensus.rs +++ b/types/src/consensus.rs @@ -1,19 +1,405 @@ -use commonware_consensus::simplex::scheme::bls12381_threshold::vrf; -use commonware_consensus::simplex::types::{ - Activity as CActivity, Context as CContext, Finalization as CFinalization, - Notarization as CNotarization, +use commonware_consensus::simplex::{ + scheme::bls12381_threshold::{standard, vrf}, + types::{ + Activity as CActivity, Context as CContext, Finalization as CFinalization, + Notarization as CNotarization, Subject, + }, }; use commonware_cryptography::{ - bls12381::primitives::variant::{MinSig, Variant}, + bls12381::{ + certificate::threshold::Certificate as StandardCertificate, + primitives::{ + group::Share, + sharing::Sharing, + variant::{MinSig, Variant}, + }, + }, + certificate::{ + Attestation, Scheme as CertificateScheme, Verification, Verifier as CertificateVerifier, + }, ed25519, sha256::Digest, }; +use commonware_parallel::Strategy; +use commonware_utils::{ordered::Set, N3f1, Participant}; +use rand::CryptoRng; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; -pub type Context = CContext; +type StandardScheme = standard::Scheme; +type VrfScheme = vrf::Scheme; +type ConsensusSignature = vrf::Signature; + +/// Certificate construction used by a consensus network. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CertificateMode { + /// One threshold signature over each vote. Stable round-robin does not require a VRF. + Standard, + /// Vote and round-seed threshold signatures for certificate-seeded rotating leaders. + Vrf, +} + +#[derive(Clone, Debug)] +enum Inner { + Standard(StandardScheme), + Vrf(VrfScheme), +} + +/// Alto's mode-aware threshold certificate scheme. +/// +/// Consensus framing retains the existing fixed-size vote/seed pair. Standard certificates put +/// the vote signature in both fields; mode-aware assembly and verification require them to match, +/// while cryptographic verification and recovery delegate to Commonware's one-signature standard +/// threshold scheme. +#[derive(Clone, Debug)] +pub struct Scheme(Inner); + +impl Scheme { + pub fn signer( + mode: CertificateMode, + namespace: &[u8], + participants: Set, + polynomial: Sharing, + share: Share, + ) -> Option { + match mode { + CertificateMode::Standard => { + StandardScheme::signer(namespace, participants, polynomial, share) + .map(Inner::Standard) + } + CertificateMode::Vrf => { + VrfScheme::signer(namespace, participants, polynomial, share).map(Inner::Vrf) + } + } + .map(Self) + } + + pub fn verifier( + mode: CertificateMode, + namespace: &[u8], + participants: Set, + polynomial: Sharing, + ) -> Self { + match mode { + CertificateMode::Standard => Self(Inner::Standard(StandardScheme::verifier( + namespace, + participants, + polynomial, + ))), + CertificateMode::Vrf => Self(Inner::Vrf(VrfScheme::verifier( + namespace, + participants, + polynomial, + ))), + } + } + + pub fn certificate_verifier( + mode: CertificateMode, + namespace: &[u8], + identity: Identity, + ) -> Self { + match mode { + CertificateMode::Standard => Self(Inner::Standard( + StandardScheme::certificate_verifier(namespace, identity), + )), + CertificateMode::Vrf => Self(Inner::Vrf(VrfScheme::certificate_verifier( + namespace, identity, + ))), + } + } + + #[doc(hidden)] + pub fn from_vrf(inner: VrfScheme) -> Self { + Self(Inner::Vrf(inner)) + } + + pub fn identity(&self) -> &Identity { + match &self.0 { + Inner::Standard(scheme) => scheme.identity(), + Inner::Vrf(scheme) => scheme.identity(), + } + } + + pub fn verify_seed(&self, seed: &Seed) -> bool { + match &self.0 { + Inner::Standard(_) => false, + Inner::Vrf(scheme) => seed.verify(scheme), + } + } + + /// Extracts the round-seed signature after a caller has verified a VRF certificate. + /// + /// This checks the mode's canonical framing but does not authenticate the certificate. + pub fn verified_vrf_seed_signature( + certificate: &::Certificate, + ) -> Option { + let signature = certificate.get()?; + (signature.vote_signature != signature.seed_signature).then_some(signature.seed_signature) + } +} + +fn standard_pair(signature: Signature) -> ConsensusSignature { + ConsensusSignature { + vote_signature: signature, + seed_signature: signature, + } +} + +fn standard_signature(signature: &ConsensusSignature) -> Option { + (signature.vote_signature == signature.seed_signature).then_some(signature.vote_signature) +} + +fn wrap_vrf_attestation(attestation: Attestation) -> Attestation { + Attestation { + signer: attestation.signer, + signature: attestation.signature, + } +} + +fn wrap_standard_attestation(attestation: Attestation) -> Attestation { + let signature = *attestation + .signature + .get() + .expect("locally constructed standard signature must decode"); + Attestation { + signer: attestation.signer, + signature: standard_pair(signature).into(), + } +} + +impl CertificateVerifier for Scheme { + type Subject<'a, D: commonware_cryptography::Digest> = Subject<'a, D>; + type Faults = N3f1; + type PublicKey = PublicKey; + type Certificate = vrf::Certificate; + + fn verify_certificate( + &self, + rng: &mut R, + subject: Subject<'_, D>, + certificate: &Self::Certificate, + strategy: &impl Strategy, + ) -> bool + where + R: CryptoRng, + D: commonware_cryptography::Digest, + { + match &self.0 { + Inner::Standard(scheme) => { + let Some(signature) = certificate.get().and_then(standard_signature) else { + return false; + }; + let certificate = StandardCertificate::new(signature); + scheme.verify_certificate(rng, subject, &certificate, strategy) + } + Inner::Vrf(scheme) => scheme.verify_certificate(rng, subject, certificate, strategy), + } + } + + fn verify_certificates<'a, R, D, I>( + &self, + rng: &mut R, + certificates: I, + strategy: &impl Strategy, + ) -> bool + where + R: CryptoRng, + D: commonware_cryptography::Digest, + I: Iterator, &'a Self::Certificate)>, + { + match &self.0 { + Inner::Standard(scheme) => { + let Some(certificates) = certificates + .map(|(subject, certificate)| { + let signature = certificate.get().and_then(standard_signature)?; + Some((subject, StandardCertificate::new(signature))) + }) + .collect::>>() + else { + return false; + }; + scheme.verify_certificates( + rng, + certificates + .iter() + .map(|(subject, certificate)| (*subject, certificate)), + strategy, + ) + } + Inner::Vrf(scheme) => scheme.verify_certificates(rng, certificates, strategy), + } + } + + fn is_batchable() -> bool { + true + } + + fn certificate_codec_config(&self) {} + + fn certificate_codec_config_unbounded() {} +} + +impl CertificateScheme for Scheme { + type Signature = ConsensusSignature; + + fn me(&self) -> Option { + match &self.0 { + Inner::Standard(scheme) => scheme.me(), + Inner::Vrf(scheme) => scheme.me(), + } + } -pub type Scheme = vrf::Scheme; + fn participants(&self) -> &Set { + match &self.0 { + Inner::Standard(scheme) => scheme.participants(), + Inner::Vrf(scheme) => scheme.participants(), + } + } + + fn sign( + &self, + subject: Subject<'_, D>, + ) -> Option> { + match &self.0 { + Inner::Standard(scheme) => scheme.sign(subject).map(wrap_standard_attestation), + Inner::Vrf(scheme) => scheme.sign(subject).map(wrap_vrf_attestation), + } + } + + fn verify_attestation( + &self, + rng: &mut R, + subject: Subject<'_, D>, + attestation: &Attestation, + strategy: &impl Strategy, + ) -> bool + where + R: CryptoRng, + D: commonware_cryptography::Digest, + { + match &self.0 { + Inner::Standard(scheme) => { + let Some(signature) = attestation.signature.get().and_then(standard_signature) + else { + return false; + }; + let attestation = Attestation:: { + signer: attestation.signer, + signature: signature.into(), + }; + scheme.verify_attestation(rng, subject, &attestation, strategy) + } + Inner::Vrf(scheme) => { + let attestation = Attestation:: { + signer: attestation.signer, + signature: attestation.signature.clone(), + }; + scheme.verify_attestation(rng, subject, &attestation, strategy) + } + } + } + + fn verify_attestations( + &self, + rng: &mut R, + subject: Subject<'_, D>, + attestations: I, + strategy: &impl Strategy, + ) -> Verification + where + R: CryptoRng, + D: commonware_cryptography::Digest, + I: IntoIterator>, + I::IntoIter: Send, + { + match &self.0 { + Inner::Standard(scheme) => { + let mut rejected = BTreeSet::new(); + let attestations = attestations + .into_iter() + .filter_map(|attestation| { + let signer = attestation.signer; + let Some(signature) = + attestation.signature.get().and_then(standard_signature) + else { + rejected.insert(signer); + return None; + }; + Some(Attestation:: { + signer, + signature: signature.into(), + }) + }) + .collect::>(); + let verification = scheme.verify_attestations(rng, subject, attestations, strategy); + rejected.extend(verification.invalid); + Verification::new( + verification + .verified + .into_iter() + .map(wrap_standard_attestation) + .collect(), + rejected.into_iter().collect(), + ) + } + Inner::Vrf(scheme) => { + let attestations = attestations.into_iter().map(|attestation| Attestation { + signer: attestation.signer, + signature: attestation.signature, + }); + let verification = scheme.verify_attestations(rng, subject, attestations, strategy); + Verification::new( + verification + .verified + .into_iter() + .map(wrap_vrf_attestation) + .collect(), + verification.invalid, + ) + } + } + } + + fn assemble(&self, attestations: I, strategy: &impl Strategy) -> Option + where + I: IntoIterator>, + I::IntoIter: Send, + { + match &self.0 { + Inner::Standard(scheme) => { + let attestations = attestations + .into_iter() + .map(|attestation| { + let signature = attestation.signature.get().and_then(standard_signature)?; + Some(Attestation:: { + signer: attestation.signer, + signature: signature.into(), + }) + }) + .collect::>>()?; + let certificate = scheme.assemble(attestations, strategy)?; + let signature = *certificate.get()?; + Some(standard_pair(signature).into()) + } + Inner::Vrf(scheme) => { + let attestations = attestations.into_iter().map(|attestation| Attestation { + signer: attestation.signer, + signature: attestation.signature, + }); + scheme.assemble(attestations, strategy) + } + } + } + + fn is_attributable() -> bool { + false + } +} + +pub type Context = CContext; pub type Seed = vrf::Seed; -pub use vrf::Seedable; pub type Notarization = CNotarization; pub type Finalization = CFinalization; pub type Activity = CActivity; @@ -21,3 +407,189 @@ pub type Activity = CActivity; pub type PublicKey = ed25519::PublicKey; pub type Identity = ::Public; pub type Signature = ::Signature; + +pub trait Seedable { + /// Returns the embedded VRF seed, if present. The containing certificate must be verified + /// before the seed is used as authenticated network data. + fn seed(&self) -> Option; +} + +impl Seedable for Notarization { + fn seed(&self) -> Option { + Scheme::verified_vrf_seed_signature(&self.certificate) + .map(|signature| Seed::new(self.proposal.round, signature)) + } +} + +impl Seedable for Finalization { + fn seed(&self) -> Option { + Scheme::verified_vrf_seed_signature(&self.certificate) + .map(|signature| Seed::new(self.proposal.round, signature)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use commonware_codec::Encode; + use commonware_consensus::{ + simplex::types::{verify_certificates, Notarization, Notarize, Proposal}, + types::{Epoch, Round, View}, + }; + use commonware_cryptography::{certificate::mocks::Fixture, sha256, Digest as _}; + use commonware_parallel::Sequential; + use rand::{rngs::StdRng, SeedableRng}; + + fn schemes(mode: CertificateMode, seed: u64) -> Vec { + let mut rng = StdRng::seed_from_u64(seed); + let Fixture { schemes, .. } = vrf::fixture::(&mut rng, b"test", 4); + schemes + .iter() + .map(|scheme| { + Scheme::signer( + mode, + b"test", + scheme.participants().clone(), + scheme.polynomial().clone(), + scheme.share().unwrap().clone(), + ) + .unwrap() + }) + .collect() + } + + fn notarization(schemes: &[Scheme], view: u64) -> crate::Notarization { + let proposal = Proposal::new( + Round::new(Epoch::new(1), View::new(view)), + View::new(view - 1), + sha256::Digest::EMPTY, + ); + let votes: Vec<_> = schemes + .iter() + .map(|scheme| Notarize::sign(scheme, proposal.clone()).unwrap()) + .collect(); + Notarization::from_notarizes(&schemes[0], &votes, &Sequential).unwrap() + } + + #[test] + fn standard_threshold_certificates_do_not_export_vrf_seeds() { + let schemes = schemes(CertificateMode::Standard, 1); + let notarization = notarization(&schemes, 9); + let vrf_verifier = + Scheme::certificate_verifier(CertificateMode::Vrf, b"test", *schemes[0].identity()); + + assert!(notarization.seed().is_none()); + assert!(notarization.verify(&mut StdRng::seed_from_u64(2), &schemes[0], &Sequential)); + assert!(!notarization.verify(&mut StdRng::seed_from_u64(2), &vrf_verifier, &Sequential,)); + } + + #[test] + fn standard_threshold_certificates_reject_noncanonical_second_signature() { + let schemes = schemes(CertificateMode::Standard, 3); + let mut certificate = notarization(&schemes, 9); + let other = notarization(&schemes, 10); + let signature = certificate.certificate.get().unwrap().vote_signature; + let other_signature = other.certificate.get().unwrap().seed_signature; + certificate.certificate = ConsensusSignature { + vote_signature: signature, + seed_signature: other_signature, + } + .into(); + + assert!(!certificate.verify(&mut StdRng::seed_from_u64(4), &schemes[0], &Sequential,)); + } + + #[test] + fn standard_threshold_certificates_batch_verify() { + let schemes = schemes(CertificateMode::Standard, 5); + let first = notarization(&schemes, 9); + let mut second = notarization(&schemes, 10); + let second_vote = second.certificate.get().unwrap().vote_signature; + let first_vote = first.certificate.get().unwrap().vote_signature; + second.certificate = ConsensusSignature { + vote_signature: second_vote, + seed_signature: first_vote, + } + .into(); + let certificates = [ + ( + Subject::Notarize { + proposal: &first.proposal, + }, + &first.certificate, + ), + ( + Subject::Notarize { + proposal: &second.proposal, + }, + &second.certificate, + ), + ]; + + assert_eq!( + verify_certificates( + &mut StdRng::seed_from_u64(6), + &schemes[0], + &certificates, + &Sequential, + ), + vec![true, false] + ); + } + + #[test] + fn vrf_mode_preserves_legacy_certificate_bytes() { + let mut rng = StdRng::seed_from_u64(7); + let Fixture { schemes, .. } = vrf::fixture::(&mut rng, b"test", 4); + let wrapped: Vec<_> = schemes + .iter() + .map(|scheme| { + Scheme::signer( + CertificateMode::Vrf, + b"test", + scheme.participants().clone(), + scheme.polynomial().clone(), + scheme.share().unwrap().clone(), + ) + .unwrap() + }) + .collect(); + let proposal = Proposal::new( + Round::new(Epoch::new(1), View::new(9)), + View::new(8), + sha256::Digest::EMPTY, + ); + let legacy_votes: Vec<_> = schemes + .iter() + .map(|scheme| Notarize::sign(scheme, proposal.clone()).unwrap()) + .collect(); + let wrapped_votes: Vec<_> = wrapped + .iter() + .map(|scheme| Notarize::sign(scheme, proposal.clone()).unwrap()) + .collect(); + let legacy = Notarization::from_notarizes(&schemes[0], &legacy_votes, &Sequential).unwrap(); + let adapted = + Notarization::from_notarizes(&wrapped[0], &wrapped_votes, &Sequential).unwrap(); + + assert_eq!(legacy.certificate.encode(), adapted.certificate.encode()); + } + + #[test] + fn vrf_threshold_certificates_retain_round_seed() { + let schemes = schemes(CertificateMode::Vrf, 9); + let notarization = notarization(&schemes, 9); + let standard_verifier = Scheme::certificate_verifier( + CertificateMode::Standard, + b"test", + *schemes[0].identity(), + ); + + assert!(notarization.seed().is_some()); + assert!(notarization.verify(&mut StdRng::seed_from_u64(8), &schemes[0], &Sequential)); + assert!(!notarization.verify( + &mut StdRng::seed_from_u64(8), + &standard_verifier, + &Sequential, + )); + } +} diff --git a/types/src/lib.rs b/types/src/lib.rs index 833e0cd1..a8a14f7c 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -10,8 +10,8 @@ pub use block::{Block, Finalized, Notarized}; mod consensus; pub use consensus::{ - Activity, Context, Finalization, Identity, Notarization, PublicKey, Scheme, Seed, Seedable, - Signature, + Activity, CertificateMode, Context, Finalization, Identity, Notarization, PublicKey, Scheme, + Seed, Seedable, Signature, }; pub mod wasm; @@ -114,6 +114,7 @@ mod tests { let n = 4; let Fixture { schemes, .. } = bls12381_threshold::fixture::(&mut rng, NAMESPACE, n); + let schemes: Vec<_> = schemes.into_iter().map(Scheme::from_vrf).collect(); // Create a block with context let context = Context { @@ -160,6 +161,7 @@ mod tests { let n = 4; let Fixture { schemes, .. } = bls12381_threshold::fixture::(&mut rng, NAMESPACE, n); + let schemes: Vec<_> = schemes.into_iter().map(Scheme::from_vrf).collect(); // Create a block with context let context = Context { diff --git a/types/src/wasm.rs b/types/src/wasm.rs index ff5fef04..277fcb63 100644 --- a/types/src/wasm.rs +++ b/types/src/wasm.rs @@ -1,4 +1,7 @@ -use crate::{Block, Finalized, Identity, Notarized, Scheme, Seed, Signature, EPOCH, NAMESPACE}; +use crate::{ + Block, CertificateMode, Finalized, Identity, Notarized, Scheme, Seed, Signature, EPOCH, + NAMESPACE, +}; use commonware_codec::{DecodeExt, Encode}; use commonware_consensus::{ simplex::elector::Random, @@ -48,12 +51,13 @@ pub struct FinalizedJs { #[wasm_bindgen] pub fn parse_seed(identity: Vec, bytes: Vec) -> JsValue { let identity = Identity::decode(identity.as_ref()).expect("invalid identity"); - let certificate_verifier = Scheme::certificate_verifier(NAMESPACE, identity); + let certificate_verifier = + Scheme::certificate_verifier(CertificateMode::Vrf, NAMESPACE, identity); let Ok(seed) = Seed::decode(bytes.as_ref()) else { return JsValue::NULL; }; - if !seed.verify(&certificate_verifier) { + if !certificate_verifier.verify_seed(&seed) { return JsValue::NULL; } let seed_js = SeedJs { @@ -64,9 +68,14 @@ pub fn parse_seed(identity: Vec, bytes: Vec) -> JsValue { } #[wasm_bindgen] -pub fn parse_notarized(identity: Vec, bytes: Vec) -> JsValue { +pub fn parse_notarized(identity: Vec, bytes: Vec, standard: bool) -> JsValue { let identity = Identity::decode(identity.as_ref()).expect("invalid identity"); - let certificate_verifier = Scheme::certificate_verifier(NAMESPACE, identity); + let mode = if standard { + CertificateMode::Standard + } else { + CertificateMode::Vrf + }; + let certificate_verifier = Scheme::certificate_verifier(mode, NAMESPACE, identity); let Ok(notarized) = Notarized::decode(bytes.as_ref()) else { return JsValue::NULL; @@ -96,9 +105,14 @@ pub fn parse_notarized(identity: Vec, bytes: Vec) -> JsValue { } #[wasm_bindgen] -pub fn parse_finalized(identity: Vec, bytes: Vec) -> JsValue { +pub fn parse_finalized(identity: Vec, bytes: Vec, standard: bool) -> JsValue { let identity = Identity::decode(identity.as_ref()).expect("invalid identity"); - let certificate_verifier = Scheme::certificate_verifier(NAMESPACE, identity); + let mode = if standard { + CertificateMode::Standard + } else { + CertificateMode::Vrf + }; + let certificate_verifier = Scheme::certificate_verifier(mode, NAMESPACE, identity); let Ok(finalized) = Finalized::decode(bytes.as_ref()) else { return JsValue::NULL; }; diff --git a/validator/src/main.rs b/validator/src/main.rs index ed489a87..86fbb06c 100644 --- a/validator/src/main.rs +++ b/validator/src/main.rs @@ -359,7 +359,12 @@ fn main() { // Create indexer let mut indexer = None; if let Some(indexer_url) = config.indexer.as_deref() { - indexer = Some(Client::new(indexer_url, *identity, strategy.clone())); + indexer = Some(Client::new_with_certificate_mode( + indexer_url, + *identity, + config.leader.certificate_mode(), + strategy.clone(), + )); } // Create engine From c2898868d12f5f0c1c0c14d23ccdd50f6b19699a Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Wed, 12 Aug 2026 11:44:08 -0700 Subject: [PATCH 5/9] improvements --- explorer/src/AboutModal.tsx | 5 ++++ explorer/src/App.css | 15 ++++++++---- explorer/src/App.tsx | 27 ++++++++++++++++----- explorer/src/timelineIdentifier.test.ts | 31 +++++++++++++++++++++++++ explorer/src/timelineIdentifier.ts | 26 +++++++++++++++++++++ 5 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 explorer/src/timelineIdentifier.test.ts create mode 100644 explorer/src/timelineIdentifier.ts diff --git a/explorer/src/AboutModal.tsx b/explorer/src/AboutModal.tsx index 72976f7d..7aea2378 100644 --- a/explorer/src/AboutModal.tsx +++ b/explorer/src/AboutModal.tsx @@ -1,5 +1,6 @@ import React, { useEffect } from 'react'; import { MODE } from './config'; +import { getTimelineIdentifier } from './timelineIdentifier'; interface AboutModalProps { isOpen: boolean; @@ -8,6 +9,7 @@ interface AboutModalProps { } const AboutModal: React.FC = ({ isOpen, onClose, standardCertificates }) => { + const timelineIdentifier = getTimelineIdentifier(standardCertificates); // Add effect to handle link targets useEffect(() => { if (isOpen) { @@ -103,6 +105,9 @@ const AboutModal: React.FC = ({ isOpen, onClose, standardCertif The block b in view v has received 2f+1 finalizes. The block is now immutable. +

    + The short {timelineIdentifier.label.toLowerCase()} beneath each view number identifies {standardCertificates ? 'the block proposed in that view' : 'the seed used for leader election'}. +

    You can read more about the design of simplex here.

    diff --git a/explorer/src/App.css b/explorer/src/App.css index 4b8fcf51..ccc1ccfc 100644 --- a/explorer/src/App.css +++ b/explorer/src/App.css @@ -101,6 +101,12 @@ body { white-space: nowrap; } +.legend-text-marker { + min-width: 20px; + font-size: 10px; + line-height: 12px; +} + /* Bars container */ .bars-header { display: flex; @@ -154,9 +160,8 @@ body { color: black; } -.view-signature { +.view-identifier { font-size: 11px; - color: #0000eeff; text-overflow: ellipsis; overflow: hidden; } @@ -437,7 +442,7 @@ body { font-size: 12px; } - .view-signature { + .view-identifier { font-size: 12px; } @@ -499,7 +504,7 @@ body { font-size: 12px; } - .view-signature { + .view-identifier { font-size: 9px; } @@ -624,4 +629,4 @@ body { -webkit-font-feature-settings: "liga" 0; font-feature-settings: "liga" 0; font-variant-emoji: text; -} \ No newline at end of file +} diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index 941ab838..81e77f1f 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -21,6 +21,7 @@ import { VerifiedConsensusArtifact, } from "./consensusWorkerPool"; import { scaleTimelineWidth } from "./timeline"; +import { getTimelineIdentifier, SEED_SIGNATURE_COLOR } from "./timelineIdentifier"; import "./App.css"; import AboutModal from './AboutModal'; import './AboutModal.css'; @@ -118,6 +119,7 @@ const App: React.FC = () => { const allConfigs = useMemo(() => getClusters(), []); const { BACKEND_URL, PUBLIC_KEY_HEX, LOCATIONS, PARTICIPANTS } = clusterConfig; const standardCertificates = clusterConfig.CERTIFICATE_MODE === 'standard'; + const timelineIdentifier = getTimelineIdentifier(standardCertificates); const PUBLIC_KEY = useMemo(() => hexToUint8Array(PUBLIC_KEY_HEX), [PUBLIC_KEY_HEX]); const [views, setViews] = useState([]); @@ -941,9 +943,10 @@ const App: React.FC = () => {

    Timeline

    - + +
    @@ -954,6 +957,7 @@ const App: React.FC = () => { viewData={viewData} currentTime={currentTimeRef.current} isMobile={isMobile} + standardCertificates={standardCertificates} /> ))} @@ -991,12 +995,17 @@ const App: React.FC = () => { interface LegendItemProps { color: string; label: string; + textMarker?: boolean; } -const LegendItem: React.FC = ({ color, label }) => { +const LegendItem: React.FC = ({ color, label, textMarker = false }) => { return (
    -
    + {textMarker ? ( + abc + ) : ( +
    + )} {label}
    ); @@ -1006,13 +1015,15 @@ interface BarProps { viewData: ViewData; currentTime: number; isMobile: boolean; + standardCertificates: boolean; maxContainerWidth?: number; } // Replace the existing Bar component with this updated version -const Bar: React.FC = ({ viewData, currentTime, isMobile }) => { +const Bar: React.FC = ({ viewData, currentTime, isMobile, standardCertificates }) => { const { view, status, startTime, notarizationTime, finalizationTime, signature, block, actualNotarizationLatency, actualFinalizationLatency } = viewData; + const timelineIdentifier = getTimelineIdentifier(standardCertificates, signature, block?.digest); const [measuredWidth, setMeasuredWidth] = useState(isMobile ? 200 : 500); // Reasonable default const barContainerRef = useRef(null); @@ -1190,8 +1201,12 @@ const Bar: React.FC = ({ viewData, currentTime, isMobile }) => {
    {view}
    -
    - {signature ? hexUint8Array(signature) : ""} +
    + {timelineIdentifier.value}
    diff --git a/explorer/src/timelineIdentifier.test.ts b/explorer/src/timelineIdentifier.test.ts new file mode 100644 index 00000000..282315f1 --- /dev/null +++ b/explorer/src/timelineIdentifier.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@jest/globals"; +import { + BLOCK_HASH_COLOR, + getTimelineIdentifier, + SEED_SIGNATURE_COLOR, +} from "./timelineIdentifier"; + +const seedSignature = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]); +const blockDigest = new Uint8Array([1, 2, 3, 4, 5, 6]); + +test("shows the block hash for standard round-robin certificates", () => { + expect( + getTimelineIdentifier(true, seedSignature, blockDigest), + ).toEqual({ + label: "Block hash", + color: BLOCK_HASH_COLOR, + value: "03040506", + fullValue: "010203040506", + }); +}); + +test("shows the seed signature for VRF certificates", () => { + expect( + getTimelineIdentifier(false, seedSignature, blockDigest), + ).toEqual({ + label: "Seed signature", + color: SEED_SIGNATURE_COLOR, + value: "beefcafe", + fullValue: "deadbeefcafe", + }); +}); diff --git a/explorer/src/timelineIdentifier.ts b/explorer/src/timelineIdentifier.ts new file mode 100644 index 00000000..b437d93d --- /dev/null +++ b/explorer/src/timelineIdentifier.ts @@ -0,0 +1,26 @@ +import { hexUint8Array } from "./utils"; + +export const SEED_SIGNATURE_COLOR = "#0000eeff"; +export const BLOCK_HASH_COLOR = "#9a3412"; + +export interface TimelineIdentifier { + label: string; + color: string; + value: string; + fullValue: string; +} + +export const getTimelineIdentifier = ( + standardCertificates: boolean, + seedSignature?: Uint8Array, + blockDigest?: Uint8Array, +): TimelineIdentifier => { + const bytes = standardCertificates ? blockDigest : seedSignature; + + return { + label: standardCertificates ? "Block hash" : "Seed signature", + color: standardCertificates ? BLOCK_HASH_COLOR : SEED_SIGNATURE_COLOR, + value: hexUint8Array(bytes), + fullValue: hexUint8Array(bytes, bytes ? bytes.length * 2 : 0), + }; +}; From 1ef2d7c5a8c03416b91866614e701929ce42f7d3 Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Wed, 12 Aug 2026 12:30:33 -0700 Subject: [PATCH 6/9] fix parallelism --- types/src/consensus.rs | 188 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 171 insertions(+), 17 deletions(-) diff --git a/types/src/consensus.rs b/types/src/consensus.rs index dd35b417..807f0120 100644 --- a/types/src/consensus.rs +++ b/types/src/consensus.rs @@ -316,24 +316,21 @@ impl CertificateScheme for Scheme { { match &self.0 { Inner::Standard(scheme) => { - let mut rejected = BTreeSet::new(); - let attestations = attestations - .into_iter() - .filter_map(|attestation| { + let (attestations, rejected) = + strategy.map_partition_collect_vec(attestations.into_iter(), |attestation| { let signer = attestation.signer; - let Some(signature) = - attestation.signature.get().and_then(standard_signature) - else { - rejected.insert(signer); - return None; - }; - Some(Attestation:: { - signer, - signature: signature.into(), - }) - }) - .collect::>(); + let converted = attestation + .signature + .get() + .and_then(standard_signature) + .map(|signature| Attestation:: { + signer, + signature: signature.into(), + }); + (signer, converted) + }); let verification = scheme.verify_attestations(rng, subject, attestations, strategy); + let mut rejected: BTreeSet<_> = rejected.into_iter().collect(); rejected.extend(verification.invalid); Verification::new( verification @@ -437,8 +434,128 @@ mod tests { types::{Epoch, Round, View}, }; use commonware_cryptography::{certificate::mocks::Fixture, sha256, Digest as _}; - use commonware_parallel::Sequential; + use commonware_parallel::{Manual, Sequential}; use rand::{rngs::StdRng, SeedableRng}; + use std::{ + future::Future, + num::NonZeroUsize, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + }; + + #[derive(Clone, Debug, Default)] + struct CountingStrategy { + partition_calls: Arc, + } + + impl CountingStrategy { + fn partition_calls(&self) -> usize { + self.partition_calls.load(Ordering::Relaxed) + } + } + + impl Strategy for CountingStrategy { + fn manual(&self) -> Manual { + Manual::new(self.clone(), NonZeroUsize::MIN) + } + + fn spawn(&self, f: F) -> impl Future + Send + 'static + where + F: FnOnce(Self) -> T + Send + 'static, + T: Send + 'static, + { + let strategy = self.clone(); + async move { f(strategy) } + } + + fn run(&self, len: usize, serial: SEQ, parallel: PAR) -> R + where + R: Send, + SEQ: FnOnce() -> R + Send, + PAR: FnOnce() -> R + Send, + { + Sequential.run(len, serial, parallel) + } + + fn try_run(&self, len: usize, serial: SEQ, parallel: PAR) -> Result + where + R: Send, + E: Send, + SEQ: FnOnce() -> Result + Send, + PAR: FnOnce() -> Result + Send, + { + Sequential.try_run(len, serial, parallel) + } + + fn fold_init( + &self, + iter: I, + init: INIT, + identity: ID, + fold_op: F, + reduce_op: RD, + ) -> R + where + I: IntoIterator + Send, + INIT: Fn() -> T + Send + Sync, + T: Send, + R: Send, + ID: Fn() -> R + Send + Sync, + F: Fn(R, &mut T, I::Item) -> R + Send + Sync, + RD: Fn(R, R) -> R + Send + Sync, + { + Sequential.fold_init(iter, init, identity, fold_op, reduce_op) + } + + fn try_fold( + &self, + iter: I, + identity: ID, + fold_op: F, + reduce_op: RD, + ) -> Result + where + I: IntoIterator + Send, + R: Send, + E: Send, + ID: Fn() -> R + Send + Sync, + F: Fn(R, I::Item) -> Result + Send + Sync, + RD: Fn(R, R) -> R + Send + Sync, + { + Sequential.try_fold(iter, identity, fold_op, reduce_op) + } + + fn map_partition_collect_vec(&self, iter: I, map_op: F) -> (Vec, Vec) + where + I: IntoIterator + Send, + F: Fn(I::Item) -> (K, Option) + Send + Sync, + K: Send, + U: Send, + { + self.partition_calls.fetch_add(1, Ordering::Relaxed); + Sequential.map_partition_collect_vec(iter, map_op) + } + + fn join(&self, a: A, b: B) -> (RA, RB) + where + A: FnOnce() -> RA + Send, + B: FnOnce() -> RB + Send, + RA: Send, + RB: Send, + { + Sequential.join(a, b) + } + + fn sort_by(&self, items: &mut [T], compare: C) + where + T: Send, + C: Fn(&T, &T) -> std::cmp::Ordering + Send + Sync, + { + Sequential.sort_by(items, compare); + } + } fn schemes(mode: CertificateMode, seed: u64) -> Vec { let mut rng = StdRng::seed_from_u64(seed); @@ -537,6 +654,43 @@ mod tests { ); } + #[test] + fn standard_threshold_batch_uses_strategy_for_adapter_decoding() { + let schemes = schemes(CertificateMode::Standard, 11); + let proposal = Proposal::new( + Round::new(Epoch::new(1), View::new(9)), + View::new(8), + sha256::Digest::EMPTY, + ); + let attestations = schemes + .iter() + .map(|scheme| { + scheme + .sign(Subject::Notarize { + proposal: &proposal, + }) + .unwrap() + }) + .collect::>(); + let strategy = CountingStrategy::default(); + + let verification = schemes[0].verify_attestations( + &mut StdRng::seed_from_u64(12), + Subject::Notarize { + proposal: &proposal, + }, + attestations, + &strategy, + ); + + assert_eq!(verification.verified.len(), schemes.len()); + assert!(verification.invalid.is_empty()); + assert!( + strategy.partition_calls() >= 2, + "the adapter and standard verifier must both use the parallel strategy" + ); + } + #[test] fn vrf_mode_preserves_legacy_certificate_bytes() { let mut rng = StdRng::seed_from_u64(7); From fde1678edc04bd6e412dd8066dd284dd9b6deb4e Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Wed, 12 Aug 2026 15:41:16 -0700 Subject: [PATCH 7/9] progress --- chain/src/application.rs | 19 +- chain/src/engine.rs | 244 ++----- chain/src/indexer/backfiller/consumer.rs | 19 +- chain/src/indexer/mocks.rs | 8 +- chain/src/indexer/mod.rs | 33 +- chain/src/indexer/pusher.rs | 16 +- chain/src/lib.rs | 40 +- client/src/consensus.rs | 38 +- client/src/lib.rs | 74 +- client/src/utils.rs | 3 +- deploy/README.md | 19 +- explorer/scripts/check-wasm.mjs | 19 + explorer/src/alto_types/alto_types_bg.wasm | Bin 249733 -> 253287 bytes follower/src/application.rs | 8 +- follower/src/archive.rs | 30 +- follower/src/engine.rs | 22 +- follower/src/feeder.rs | 14 +- follower/src/main.rs | 168 ++--- follower/src/resolver.rs | 46 +- follower/src/test_utils.rs | 36 +- indexer/src/lib.rs | 190 +++-- indexer/src/main.rs | 19 +- inspector/src/main.rs | 29 +- inspector/src/utils.rs | 6 +- types/src/block.rs | 56 +- types/src/consensus.rs | 793 +++++---------------- types/src/lib.rs | 6 +- types/src/wasm.rs | 47 +- validator/src/main.rs | 113 +-- 29 files changed, 863 insertions(+), 1252 deletions(-) diff --git a/chain/src/application.rs b/chain/src/application.rs index 668189ad..6936d3eb 100644 --- a/chain/src/application.rs +++ b/chain/src/application.rs @@ -13,6 +13,7 @@ use commonware_utils::{Acknowledgement, SystemTimeExt}; use futures::StreamExt; use rand::Rng; use std::{ + marker::PhantomData, num::NonZeroU64, time::{Duration, SystemTime}, }; @@ -29,13 +30,14 @@ const MAX_BLOCK_TIMESTAMP_MS: u64 = 7_258_118_400_000; const MAX_FUTURE_SKEW_MS: u64 = 1_000; #[derive(Clone)] -pub struct Application { +pub struct Application { backfiller: Option, delay_ms: NonZeroU64, block_size: u32, + _scheme: PhantomData, } -impl Application { +impl Application { pub fn genesis() -> Block { let genesis_context = Context { round: Round::new(EPOCH, View::zero()), @@ -56,6 +58,7 @@ impl Application { backfiller: None, delay_ms, block_size: 0, + _scheme: PhantomData, } } @@ -70,11 +73,12 @@ impl Application { } } -impl ConsensusApplication for Application +impl ConsensusApplication for Application where E: Rng + Spawner + Metrics + Clock + Storage, + S: Scheme, { - type SigningScheme = Scheme; + type SigningScheme = S; type Context = Context; type Block = Block; type Input = (); @@ -156,7 +160,7 @@ where } } -impl Reporter for Application { +impl Reporter for Application { type Activity = Update; fn report(&mut self, activity: Self::Activity) -> Feedback { @@ -183,6 +187,7 @@ impl Reporter for Application { #[cfg(test)] mod tests { use super::*; + use alto_types::VrfScheme; use commonware_consensus::marshal::ancestry; use commonware_runtime::{deterministic, Runner as _, Supervisor as _}; use commonware_utils::NZU64; @@ -198,7 +203,7 @@ mod tests { async fn verify_block( context: deterministic::Context, - application: &mut Application, + application: &mut Application, block: &Block, parent: &Block, ) -> bool { @@ -208,7 +213,7 @@ mod tests { async fn propose_child( context: deterministic::Context, - application: &mut Application, + application: &mut Application, child_context: Context, parent: &Block, ) -> Block { diff --git a/chain/src/engine.rs b/chain/src/engine.rs index 4cdb0fc4..98177bc6 100644 --- a/chain/src/engine.rs +++ b/chain/src/engine.rs @@ -1,9 +1,8 @@ use crate::{ application::Application, indexer::{self, Client}, - Leader, }; -use alto_types::{Activity, Block, Finalization, Scheme, EPOCH, EPOCH_LENGTH, NAMESPACE}; +use alto_types::{Activity, Block, Finalization, Scheme, EPOCH, EPOCH_LENGTH}; use commonware_broadcast::buffered; use commonware_consensus::{ marshal::{ @@ -14,15 +13,14 @@ use commonware_consensus::{ }, simplex::{ self, - elector::{self, Random, RoundRobin, RoundRobinElector}, + elector::{self, RoundRobin}, Engine as Consensus, }, - types::{Epoch, FixedEpocher, Participant, Round, TermLength, ViewDelta}, + types::{Epoch, FixedEpocher, TermLength, ViewDelta}, Reporters, }; use commonware_cryptography::{ - bls12381::primitives::{group, sharing::Sharing, variant::MinSig}, - certificate::{ConstantProvider, Verifier as CertificateVerifier}, + certificate::ConstantProvider, ed25519::PublicKey, sha256::{Digest, Sha256}, Digestible as _, @@ -35,7 +33,7 @@ use commonware_runtime::{ spawn_cell, BufferPooler, Clock, ContextCell, Handle, Metrics, Spawner, Storage, }; use commonware_storage::{archive::prunable, queue, translator::FourCap}; -use commonware_utils::{ordered::Set, NZUsize, NZU64}; +use commonware_utils::{NZUsize, NZU64}; use futures::future::try_join_all; use governor::clock::Clock as GClock; use governor::Quota; @@ -47,8 +45,8 @@ use std::{ use tracing::{error, info, warn}; /// Reporter type for [simplex::Engine]. -type Reporter = - Reporters>, MarshalMailbox>>; +type Reporter = + Reporters, Option>, MarshalMailbox>>; /// To better support peers near tip during network instability, we multiply /// the consensus activity timeout by this factor. @@ -65,114 +63,37 @@ const MAX_REPAIR: NonZero = NZUsize!(20); const MAX_PENDING_ACKS: NonZero = NZUsize!(16); const STABLE_LEADER_STALL_TIMEOUT: Duration = Duration::from_secs(12); -/// Adapts the serialized leader policy to Simplex's statically typed elector configuration. -#[derive(Clone, Default)] -struct ElectorConfig(Leader); - -impl elector::Config for ElectorConfig { - type Elector = ConfiguredElector; - - fn build(self, participants: &Set) -> Self::Elector { - match self.0 { - Leader::Rotating { .. } => { - ConfiguredElector::Rotating(RotatingElector::new(participants)) - } - Leader::Stable { - term_length, - optimistic_views, - .. - } => ConfiguredElector::Stable(elector::Config::::build( - RoundRobin::::default().with_term( - TermLength::new(term_length), - STABLE_LEADER_STALL_TIMEOUT, - ViewDelta::new(optimistic_views), - ), - participants, - )), - } - } -} - -#[derive(Clone)] -struct RotatingElector { - participants: u32, -} - -impl RotatingElector { - fn new(participants: &Set) -> Self { - assert!(!participants.is_empty(), "no participants"); - Self { - participants: u32::try_from(participants.len()).expect("participant count fits in u32"), - } - } -} - -impl elector::Elector for RotatingElector { - fn terms(&self) -> elector::Terms { - elector::Terms::rotating() - } - - fn elect( - &self, - round: Round, - certificate: Option<&::Certificate>, - ) -> Participant { - Random::select_leader::( - round, - self.participants, - certificate.map(|certificate| { - Scheme::verified_vrf_seed_signature(certificate) - .expect("rotating leader requires a verified VRF certificate") - }), - ) - } -} +/// Round-robin leader election used with native standard certificates. +pub type StableElector = RoundRobin; -/// Holds the initialized runtime choice behind the single elector type required by Simplex. -#[derive(Clone)] -enum ConfiguredElector { - Rotating(RotatingElector), - Stable(RoundRobinElector), -} - -impl elector::Elector for ConfiguredElector { - fn terms(&self) -> elector::Terms { - match self { - Self::Rotating(elector) => elector::Elector::terms(elector), - Self::Stable(elector) => elector::Elector::terms(elector), - } - } - - fn elect( - &self, - round: Round, - certificate: Option<&::Certificate>, - ) -> Participant { - match self { - Self::Rotating(elector) => elector::Elector::elect(elector, round, certificate), - Self::Stable(elector) => elector::Elector::elect(elector, round, certificate), - } - } +/// Builds stable leader election with a bounded optimistic view window. +pub fn stable_elector(term_length: NonZero, optimistic_views: u64) -> StableElector { + RoundRobin::default().with_term( + TermLength::new(term_length), + STABLE_LEADER_STALL_TIMEOUT, + ViewDelta::new(optimistic_views), + ) } /// Configuration for the [Engine]. pub struct Config< B: Blocker, P: Provider, - C: Client, + C: Client, S: Strategy, + CS: Scheme, + L: elector::Config, > { pub blocker: B, pub provider: P, pub partition_prefix: String, pub me: PublicKey, - pub polynomial: Sharing, - pub share: group::Share, - pub participants: Set, + pub scheme: CS, + pub elector: L, pub mailbox_size: usize, pub deque_size: usize, pub block_size: u32, - pub leader: Leader, + pub proposal_delay_ms: NonZero, pub leader_timeout: Duration, pub certification_timeout: Duration, @@ -191,17 +112,19 @@ pub struct Config< pub indexer: Option, } -type Marshaled = Deferred; +type Marshaled = Deferred, Block, FixedEpocher>; /// The engine that drives the [Application]. #[allow(clippy::type_complexity)] -pub struct Engine +pub struct Engine where E: BufferPooler + Clock + GClock + Rng + CryptoRng + Spawner + Storage + Metrics, B: Blocker, P: Provider, S: Strategy, - C: Client, + C: Client, + CS: Scheme, + L: elector::Config, { context: ContextCell, @@ -210,43 +133,35 @@ where marshal: MarshalActor< E, Standard, - ConstantProvider, - prunable::Archive, + ConstantProvider, + prunable::Archive>, prunable::Archive, FixedEpocher, S, >, - marshaled: Marshaled, + marshaled: Marshaled, - consensus: Consensus< - E, - Scheme, - ElectorConfig, - B, - Digest, - Marshaled, - Marshaled, - Reporter, - S, - >, + consensus: + Consensus, Marshaled, Reporter, S>, - consumer: Option>, + consumer: Option>, } -impl Engine +impl Engine where E: BufferPooler + Clock + GClock + Rng + CryptoRng + Spawner + Storage + Metrics, B: Blocker, P: Provider, S: Strategy, - C: Client, + C: Client, + CS: Scheme, + L: elector::Config, { /// Create a new [Engine]. - pub async fn new(context: E, cfg: Config) -> Self { + pub async fn new(context: E, cfg: Config) -> Self { let mailbox_size = NonZeroUsize::new(cfg.mailbox_size).expect("mailbox size must be non-zero"); - let proposal_delay_ms = cfg.leader.delay_ms(); - let elector = ElectorConfig(cfg.leader); + let proposal_delay_ms = cfg.proposal_delay_ms; // Create the buffer let (buffer, buffer_mailbox) = buffered::Engine::new( @@ -275,7 +190,7 @@ where key_page_cache: page_cache.clone(), value_partition: format!("{}-finalizations-by-height-value", cfg.partition_prefix), compression: ARCHIVE_COMPRESSION, - codec_config: Scheme::certificate_codec_config_unbounded(), + codec_config: CS::certificate_codec_config_unbounded(), items_per_section: PRUNABLE_ITEMS_PER_SECTION, key_write_buffer: WRITE_BUFFER, value_write_buffer: WRITE_BUFFER, @@ -308,17 +223,10 @@ where info!(elapsed = ?start.elapsed(), "restored finalized blocks archive"); // Create marshal - let scheme = Scheme::signer( - cfg.leader.certificate_mode(), - NAMESPACE, - cfg.participants, - cfg.polynomial, - cfg.share, - ) - .expect("failed to create scheme"); + let scheme = cfg.scheme; let provider = ConstantProvider::new(scheme.clone()); let epocher = FixedEpocher::new(EPOCH_LENGTH); - let genesis = Application::genesis(); + let genesis = Application::::genesis(); let genesis_digest = genesis.digest(); let (marshal, marshal_mailbox, _) = MarshalActor::init( context.child("marshal"), @@ -389,7 +297,7 @@ where }; // Create the application - let marshaled = Marshaled::new( + let marshaled = Marshaled::::new( context.child("marshaled"), app, marshal_mailbox.clone(), @@ -397,7 +305,7 @@ where ); // Create the reporter. - let reporter: Reporter = (pusher, marshal_mailbox.clone()).into(); + let reporter: Reporter = (pusher, marshal_mailbox.clone()).into(); // Create the consensus engine let consensus = Consensus::new( @@ -423,7 +331,7 @@ where write_buffer: WRITE_BUFFER, blocker: cfg.blocker, page_cache, - elector, + elector: cfg.elector, strategy: cfg.strategy, }, ); @@ -539,63 +447,41 @@ where #[cfg(test)] mod tests { use super::*; + use alto_types::{StandardScheme, VrfScheme, NAMESPACE}; use commonware_consensus::{ - simplex::{scheme::bls12381_threshold::vrf, types::Subject}, - types::View, + simplex::{ + elector::Random, + scheme::bls12381_threshold::{standard, vrf}, + }, + types::{Round, View}, }; use commonware_cryptography::{ bls12381::primitives::variant::MinSig, certificate::{mocks::Fixture, Scheme as _}, - sha256::Digest as Sha256Digest, }; - use commonware_parallel::Sequential; - use commonware_utils::{Faults, N3f1, NZU32, NZU64}; + use commonware_utils::NZU32; use rand::{rngs::StdRng, SeedableRng}; #[test] - fn configured_elector_preserves_rotating_and_stable_terms() { - let Fixture { schemes, .. } = - vrf::fixture::(&mut StdRng::seed_from_u64(0), NAMESPACE, 4); - let schemes: Vec<_> = schemes.into_iter().map(Scheme::from_vrf).collect(); - let participants = schemes[0].participants(); - - let rotating_config = Leader::rotating(NZU64!(7)); - assert_eq!(rotating_config.delay_ms(), NZU64!(7)); - assert_eq!( - Leader::default(), - Leader::stable(NZU64!(10), NZU32!(1_000), 48) - ); - - let rotating = - elector::Config::::build(ElectorConfig(rotating_config), participants); + fn electors_are_specialized_for_their_certificate_schemes() { + let Fixture { + schemes: vrf_schemes, + .. + } = vrf::fixture::(&mut StdRng::seed_from_u64(0), NAMESPACE, 4); + let rotating = elector::Config::::build(Random, vrf_schemes[0].participants()); assert_eq!( elector::Elector::terms(&rotating), elector::Terms::rotating() ); - let random = RotatingElector::new(participants); - let certificate_round = Round::new(EPOCH, View::new(1)); - let attestations: Vec<_> = schemes - .iter() - .take(N3f1::quorum(schemes.len()) as usize) - .map(|scheme| { - scheme - .sign::(Subject::Nullify { - round: certificate_round, - }) - .unwrap() - }) - .collect(); - let certificate = schemes[0].assemble(attestations, &Sequential).unwrap(); - let next_round = Round::new(EPOCH, View::new(2)); - assert_eq!( - elector::Elector::elect(&rotating, next_round, Some(&certificate)), - elector::Elector::elect(&random, next_round, Some(&certificate)), - ); let term_length = NZU32!(9); - let stable = elector::Config::::build( - ElectorConfig(Leader::stable(NZU64!(10), term_length, 37)), - participants, + let Fixture { + schemes: standard_schemes, + .. + } = standard::fixture::(&mut StdRng::seed_from_u64(1), NAMESPACE, 4); + let stable = elector::Config::::build( + stable_elector(term_length, 37), + standard_schemes[0].participants(), ); let terms = elector::Elector::terms(&stable); assert_eq!(terms.length(), TermLength::new(term_length)); diff --git a/chain/src/indexer/backfiller/consumer.rs b/chain/src/indexer/backfiller/consumer.rs index bae59c9d..7dd6aeb2 100644 --- a/chain/src/indexer/backfiller/consumer.rs +++ b/chain/src/indexer/backfiller/consumer.rs @@ -30,10 +30,14 @@ enum Completion { Skipped { position: u64, height: u64 }, } -pub struct Consumer { +pub struct Consumer< + E: Spawner + Clock + Storage + Metrics + BufferPooler, + C: Client, + CS: Scheme, +> { context: ContextCell, client: C, - marshal: MarshalMailbox>, + marshal: MarshalMailbox>, upload_results: status::Counter, uploads: SharedState, writer: queue::Writer, @@ -43,11 +47,16 @@ pub struct Consumer Consumer { +impl Consumer +where + E: Spawner + Clock + Storage + Metrics + BufferPooler, + C: Client, + CS: Scheme, +{ pub fn new( context: E, client: C, - marshal: MarshalMailbox>, + marshal: MarshalMailbox>, uploads: SharedState, backfiller: (queue::Writer, queue::Reader), max_active: NonZeroUsize, @@ -218,7 +227,7 @@ impl Consumer< async fn wait_for_uploadable_block( context: &E, - marshal: &MarshalMailbox>, + marshal: &MarshalMailbox>, uploads: &SharedState, digest: Digest, retry: Duration, diff --git a/chain/src/indexer/mocks.rs b/chain/src/indexer/mocks.rs index 74bc4eac..310afca2 100644 --- a/chain/src/indexer/mocks.rs +++ b/chain/src/indexer/mocks.rs @@ -1,4 +1,4 @@ -use super::{Block, Client as IndexerClient, Finalized, Notarized, Seed}; +use super::{Block, Client as IndexerClient, Finalized, Notarized, Scheme, Seed}; use commonware_cryptography::{sha256::Digest, Digestible}; use commonware_utils::{channel::oneshot, sync::Mutex}; use std::sync::{ @@ -91,7 +91,7 @@ impl Default for Client { } } -impl IndexerClient for Client { +impl IndexerClient for Client { type Error = std::io::Error; async fn seed_upload(&self, _: Seed) -> Result<(), Self::Error> { @@ -99,7 +99,7 @@ impl IndexerClient for Client { Ok(()) } - async fn notarized_upload(&self, _: Notarized) -> Result<(), Self::Error> { + async fn notarized_upload(&self, _: Notarized) -> Result<(), Self::Error> { if self.fail_certs { return Err(std::io::Error::other("cert upload disabled")); } @@ -108,7 +108,7 @@ impl IndexerClient for Client { Ok(()) } - async fn finalized_upload(&self, _: Finalized) -> Result<(), Self::Error> { + async fn finalized_upload(&self, _: Finalized) -> Result<(), Self::Error> { if self.fail_certs { return Err(std::io::Error::other("cert upload disabled")); } diff --git a/chain/src/indexer/mod.rs b/chain/src/indexer/mod.rs index 58f26c3b..c71d8d41 100644 --- a/chain/src/indexer/mod.rs +++ b/chain/src/indexer/mod.rs @@ -32,7 +32,7 @@ use backfiller::{SharedState, State}; pub(crate) use pusher::Pusher; /// Trait for interacting with an indexer backend. -pub trait Client: Clone + Send + Sync + 'static { +pub trait Client: Clone + Send + Sync + 'static { type Error: std::error::Error + Send + Sync + 'static; /// Upload a seed to the indexer. @@ -41,20 +41,20 @@ pub trait Client: Clone + Send + Sync + 'static { /// Upload a notarization to the indexer. fn notarized_upload( &self, - notarized: Notarized, + notarized: Notarized, ) -> impl Future> + Send; /// Upload a finalization to the indexer. fn finalized_upload( &self, - finalized: Finalized, + finalized: Finalized, ) -> impl Future> + Send; /// Upload a block (without certificate) to the indexer. fn block_upload(&self, block: Block) -> impl Future> + Send; } -impl Client for alto_client::Client { +impl Client for alto_client::Client { type Error = alto_client::Error; fn seed_upload(&self, seed: Seed) -> impl Future> + Send { @@ -63,14 +63,14 @@ impl Client for alto_client::Client { fn notarized_upload( &self, - notarized: Notarized, + notarized: Notarized, ) -> impl Future> + Send { self.notarized_upload(notarized) } fn finalized_upload( &self, - finalized: Finalized, + finalized: Finalized, ) -> impl Future> + Send { self.finalized_upload(finalized) } @@ -88,17 +88,26 @@ impl Client for alto_client::Client { /// - a producer for the application's finalized block stream; /// - a pusher for consensus activity; /// - a consumer for the background retry task. -pub(crate) struct Indexer { +pub(crate) struct Indexer< + E: Spawner + Clock + Storage + Metrics + BufferPooler, + C: Client, + CS: Scheme, +> { producer: Producer, - pusher: Pusher, - consumer: Consumer, + pusher: Pusher, + consumer: Consumer, } -impl Indexer { +impl Indexer +where + E: Spawner + Clock + Storage + Metrics + BufferPooler, + C: Client, + CS: Scheme, +{ pub(crate) async fn new( context: E, client: C, - marshal: MarshalMailbox>, + marshal: MarshalMailbox>, backfiller: (queue::Writer, queue::Reader), mailbox_size: NonZeroUsize, backfiller_max_active: NonZeroUsize, @@ -136,7 +145,7 @@ impl Indexer (Producer, Pusher, Consumer) { + pub(crate) fn split(self) -> (Producer, Pusher, Consumer) { let Self { producer, pusher, diff --git a/chain/src/indexer/pusher.rs b/chain/src/indexer/pusher.rs index f0b88c9b..ec1064e1 100644 --- a/chain/src/indexer/pusher.rs +++ b/chain/src/indexer/pusher.rs @@ -18,14 +18,14 @@ use tracing::{debug, warn}; /// block in shared state, and uploads the certificate-bearing object. The /// shared state lets the backfiller reuse blocks and back off /// when the live path is already handling a digest. -pub(crate) struct Pusher { +pub(crate) struct Pusher, CS: Scheme> { context: Arc, client: C, - marshal: MarshalMailbox>, + marshal: MarshalMailbox>, uploads: SharedState, } -impl Clone for Pusher { +impl, CS: Scheme> Clone for Pusher { fn clone(&self) -> Self { Self { context: self.context.clone(), @@ -36,12 +36,12 @@ impl Clone for Pusher { } } -impl Pusher { +impl, CS: Scheme> Pusher { /// Create a new [Pusher]. pub(crate) fn new( context: E, client: C, - marshal: MarshalMailbox>, + marshal: MarshalMailbox>, uploads: SharedState, ) -> Self { Self { @@ -91,7 +91,7 @@ impl Drop for CertificateUploadGuard { } } -impl Pusher { +impl, CS: Scheme> Pusher { fn spawn_seed_upload(&self, label: &'static str, seed: Seed, view: View) { self.context.child(label).spawn({ let client = self.client.clone(); @@ -145,8 +145,8 @@ impl Pusher { } } -impl Reporter for Pusher { - type Activity = Activity; +impl, CS: Scheme> Reporter for Pusher { + type Activity = Activity; fn report(&mut self, activity: Self::Activity) -> Feedback { match activity { diff --git a/chain/src/lib.rs b/chain/src/lib.rs index 3b11549b..69409e43 100644 --- a/chain/src/lib.rs +++ b/chain/src/lib.rs @@ -115,13 +115,6 @@ impl Leader { } } - /// Minimum timestamp increase between proposals. - pub(crate) const fn delay_ms(self) -> NonZeroU64 { - match self { - Self::Rotating { delay_ms } | Self::Stable { delay_ms, .. } => delay_ms, - } - } - /// Threshold certificate construction required by this leader policy. pub const fn certificate_mode(self) -> CertificateMode { match self { @@ -200,10 +193,13 @@ mod tests { use super::*; use alto_types::NAMESPACE; use commonware_consensus::{ - marshal, simplex::scheme::bls12381_threshold::vrf as bls12381_threshold, types::ViewDelta, + marshal, simplex::scheme::bls12381_threshold::standard as bls12381_threshold, + types::ViewDelta, }; use commonware_cryptography::{ - bls12381::primitives::variant::MinSig, certificate::mocks::Fixture, ed25519::PublicKey, + bls12381::primitives::variant::MinSig, + certificate::{mocks::Fixture, Scheme as _}, + ed25519::PublicKey, Digestible, Signer, }; use commonware_macros::{select, test_traced}; @@ -217,7 +213,7 @@ mod tests { Clock, Metrics, Runner as _, Spawner, Supervisor as _, }; use commonware_utils::{channel::oneshot, ordered::Set, NZUsize, NZU32}; - use engine::{Config, Engine}; + use engine::Engine; use governor::Quota; use indexer::mocks; use rand::{rngs::StdRng, RngExt, SeedableRng}; @@ -437,18 +433,26 @@ mod tests { let public_key = signer.public_key(); let uid = format!("validator_{public_key}"); - let config: Config<_, _, mocks::Client, _> = engine::Config { + assert_eq!(scheme.participants(), &participants); + let Leader::Stable { + delay_ms, + term_length, + optimistic_views, + } = cfg.leader + else { + panic!("standard test scheme requires stable leadership"); + }; + let config = engine::Config { blocker: oracle.control(public_key.clone()), provider: oracle.manager(), partition_prefix: uid.clone(), me: signer.public_key(), - polynomial: scheme.polynomial().clone(), - share: scheme.share().cloned().unwrap(), - participants, + scheme: scheme.clone(), + elector: engine::stable_elector(term_length, optimistic_views), mailbox_size: 1024, deque_size: 10, block_size: 0, - leader: cfg.leader, + proposal_delay_ms: delay_ms, leader_timeout: cfg.leader_timeout, certification_timeout: cfg.certification_timeout, nullify_retry: timeout_retry, @@ -889,7 +893,8 @@ mod tests { assert!(indexer .finalization_seen .load(std::sync::atomic::Ordering::Relaxed)); - let genesis_digest = application::Application::genesis().digest(); + let genesis_digest = + application::Application::::genesis().digest(); let started_digests = indexer.block_upload_started_digests.lock().clone(); let expected_genesis_uploads = n as usize; assert_eq!( @@ -1074,7 +1079,8 @@ mod tests { queue_outstanding(&metrics) > 0, "expected finalized queue work while certificate uploads were blocked", ); - let genesis_digest = application::Application::genesis().digest(); + let genesis_digest = + application::Application::::genesis().digest(); let expected_genesis_uploads = n as usize; let started_digests = indexer.block_upload_started_digests.lock().clone(); assert_eq!( diff --git a/client/src/consensus.rs b/client/src/consensus.rs index cdf18489..c7a41dba 100644 --- a/client/src/consensus.rs +++ b/client/src/consensus.rs @@ -1,5 +1,5 @@ use crate::{Client, Error, IndexQuery, Query}; -use alto_types::{Block, Finalized, Kind, Notarized, Seed}; +use alto_types::{Block, Finalized, Kind, Notarized, Scheme, Seed}; use commonware_codec::{DecodeExt, Encode}; use commonware_consensus::Viewable; use commonware_cryptography::Digestible; @@ -43,18 +43,18 @@ fn listen_path(base: String) -> String { format!("{base}/consensus/ws") } -pub enum Payload { - Finalized(Box), +pub enum Payload { + Finalized(Box>), Block(Box), } -pub enum Message { +pub enum Message { Seed(Seed), - Notarization(Notarized), - Finalization(Finalized), + Notarization(Notarized), + Finalization(Finalized), } -impl Client { +impl Client { pub async fn seed_upload(&self, seed: Seed) -> Result<(), Error> { let result = self .http_client @@ -98,7 +98,7 @@ impl Client { Ok(seed) } - pub async fn notarized_upload(&self, notarized: Notarized) -> Result<(), Error> { + pub async fn notarized_upload(&self, notarized: Notarized) -> Result<(), Error> { let result = self .http_client .post(notarization_upload_path(self.uri.clone())) @@ -112,7 +112,7 @@ impl Client { Ok(()) } - pub async fn notarized_get(&self, query: IndexQuery) -> Result { + pub async fn notarized_get(&self, query: IndexQuery) -> Result, Error> { // Get the notarization let result = self .http_client @@ -124,7 +124,7 @@ impl Client { return Err(Error::Failed(result.status())); } let bytes = result.bytes().await.map_err(Error::Reqwest)?; - let notarized = Notarized::decode(bytes.as_ref()).map_err(Error::InvalidData)?; + let notarized = Notarized::::decode(bytes.as_ref()).map_err(Error::InvalidData)?; if self.verify && !notarized.verify(&self.certificate_verifier, &self.strategy) { return Err(Error::InvalidSignature); } @@ -141,7 +141,7 @@ impl Client { Ok(notarized) } - pub async fn finalized_upload(&self, finalized: Finalized) -> Result<(), Error> { + pub async fn finalized_upload(&self, finalized: Finalized) -> Result<(), Error> { let result = self .http_client .post(finalization_upload_path(self.uri.clone())) @@ -155,7 +155,7 @@ impl Client { Ok(()) } - pub async fn finalized_get(&self, query: IndexQuery) -> Result { + pub async fn finalized_get(&self, query: IndexQuery) -> Result, Error> { // Get the finalization let result = self .http_client @@ -167,7 +167,7 @@ impl Client { return Err(Error::Failed(result.status())); } let bytes = result.bytes().await.map_err(Error::Reqwest)?; - let finalized = Finalized::decode(bytes.as_ref()).map_err(Error::InvalidData)?; + let finalized = Finalized::::decode(bytes.as_ref()).map_err(Error::InvalidData)?; if self.verify && !finalized.verify(&self.certificate_verifier, &self.strategy) { return Err(Error::InvalidSignature); } @@ -199,7 +199,7 @@ impl Client { Ok(()) } - pub async fn block_get(&self, query: Query) -> Result { + pub async fn block_get(&self, query: Query) -> Result, Error> { // Get the block let result = self .http_client @@ -215,14 +215,14 @@ impl Client { // Verify the block matches the query let result = match query { Query::Latest => { - let result = Finalized::decode(bytes.as_ref()).map_err(Error::InvalidData)?; + let result = Finalized::::decode(bytes.as_ref()).map_err(Error::InvalidData)?; if self.verify && !result.verify(&self.certificate_verifier, &self.strategy) { return Err(Error::InvalidSignature); } Payload::Finalized(Box::new(result)) } Query::Index(index) => { - let result = Finalized::decode(bytes.as_ref()).map_err(Error::InvalidData)?; + let result = Finalized::::decode(bytes.as_ref()).map_err(Error::InvalidData)?; if self.verify && !result.verify(&self.certificate_verifier, &self.strategy) { return Err(Error::InvalidSignature); } @@ -242,7 +242,7 @@ impl Client { Ok(result) } - pub async fn listen(&self) -> Result>, Error> { + pub async fn listen(&self) -> Result, Error>>, Error> { // Connect to the websocket endpoint let (stream, _) = connect_async_tls_with_config( listen_path(self.ws_uri.clone()), @@ -292,7 +292,7 @@ impl Client { } } Kind::Notarization => { - let result = Notarized::decode(data); + let result = Notarized::::decode(data); match result { Ok(notarized) => { if verify @@ -314,7 +314,7 @@ impl Client { } } Kind::Finalization => { - let result = Finalized::decode(data); + let result = Finalized::::decode(data); match result { Ok(finalized) => { if verify diff --git a/client/src/lib.rs b/client/src/lib.rs index e29b0f0f..819fa7b4 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1,6 +1,6 @@ //! Interact with an `alto` indexer. -use alto_types::{CertificateMode, Identity, Scheme, NAMESPACE}; +use alto_types::{Identity, Scheme, StandardScheme, VrfScheme, NAMESPACE}; use commonware_cryptography::sha256::Digest; use commonware_formatting::hex; use commonware_parallel::Strategy; @@ -62,22 +62,42 @@ pub enum Error { type WsConnector = tokio_tungstenite::Connector; /// Builder for creating a [`Client`]. -pub struct ClientBuilder { +pub struct ClientBuilder { uri: String, ws_uri: String, - identity: Identity, - certificate_mode: CertificateMode, + certificate_verifier: C, tls_certs: Vec>, strategy: S, verify: bool, } -impl ClientBuilder { +impl ClientBuilder { /// Create a new builder for the given indexer URI. /// - /// The builder defaults to VRF certificates for compatibility with existing rotating-leader - /// networks. Stable-leader networks must call [`Self::with_certificate_mode`]. + /// The default constructor targets a rotating-leader network using VRF certificates. pub fn new(uri: &str, identity: Identity, strategy: S) -> Self { + Self::new_with_scheme( + uri, + VrfScheme::certificate_verifier(NAMESPACE, identity), + strategy, + ) + } +} + +impl ClientBuilder { + /// Create a builder for a stable-leader network using native standard certificates. + pub fn new_standard(uri: &str, identity: Identity, strategy: S) -> Self { + Self::new_with_scheme( + uri, + StandardScheme::certificate_verifier(NAMESPACE, identity), + strategy, + ) + } +} + +impl ClientBuilder { + /// Create a builder with an already initialized concrete certificate verifier. + pub fn new_with_scheme(uri: &str, certificate_verifier: C, strategy: S) -> Self { let uri = uri.to_string(); let ws_uri = if let Some(rest) = uri.strip_prefix("https://") { format!("wss://{rest}") @@ -89,20 +109,13 @@ impl ClientBuilder { Self { uri, ws_uri, - identity, - certificate_mode: CertificateMode::Vrf, + certificate_verifier, tls_certs: Vec::new(), strategy, verify: true, } } - /// Select the certificate construction used by the target network. - pub fn with_certificate_mode(mut self, certificate_mode: CertificateMode) -> Self { - self.certificate_mode = certificate_mode; - self - } - /// Disable signature verification for all returned data. pub fn with_verification_disabled(mut self) -> Self { self.verify = false; @@ -118,10 +131,7 @@ impl ClientBuilder { } /// Build the client. - pub fn build(self) -> Client { - let certificate_verifier = - Scheme::certificate_verifier(self.certificate_mode, NAMESPACE, self.identity); - + pub fn build(self) -> Client { // HTTP/2 multiplexes all requests over a single connection, so // DNS is only resolved once on the initial connect. let mut http_builder = reqwest::Client::builder() @@ -161,7 +171,7 @@ impl ClientBuilder { Client { uri: self.uri, ws_uri: self.ws_uri, - certificate_verifier, + certificate_verifier: self.certificate_verifier, verify: self.verify, http_client, ws_connector, @@ -171,10 +181,10 @@ impl ClientBuilder { } #[derive(Clone)] -pub struct Client { +pub struct Client { uri: String, ws_uri: String, - certificate_verifier: Scheme, + certificate_verifier: C, verify: bool, http_client: reqwest::Client, @@ -182,11 +192,10 @@ pub struct Client { strategy: S, } -impl Client { +impl Client { /// Create a new client for the given indexer URI. /// - /// This constructor verifies VRF certificates. Use [`Self::new_with_certificate_mode`] for a - /// stable-leader network. + /// This constructor verifies VRF certificates. /// /// TLS is automatically configured using the system's root certificates. /// For HTTPS/WSS endpoints with certificates signed by trusted CAs, @@ -197,16 +206,11 @@ impl Client { pub fn new(uri: &str, identity: Identity, strategy: S) -> Self { ClientBuilder::new(uri, identity, strategy).build() } +} - /// Create a client for a network using the selected certificate construction. - pub fn new_with_certificate_mode( - uri: &str, - identity: Identity, - certificate_mode: CertificateMode, - strategy: S, - ) -> Self { - ClientBuilder::new(uri, identity, strategy) - .with_certificate_mode(certificate_mode) - .build() +impl Client { + /// Create a client for a stable-leader network using native standard certificates. + pub fn new_standard(uri: &str, identity: Identity, strategy: S) -> Self { + ClientBuilder::new_standard(uri, identity, strategy).build() } } diff --git a/client/src/utils.rs b/client/src/utils.rs index 34283d29..2b974a72 100644 --- a/client/src/utils.rs +++ b/client/src/utils.rs @@ -1,11 +1,12 @@ use crate::{Client, Error}; +use alto_types::Scheme; use commonware_parallel::Strategy; fn healthy_path(base: String) -> String { format!("{base}/health") } -impl Client { +impl Client { pub async fn health(&self) -> Result<(), Error> { let result = self .http_client diff --git a/deploy/README.md b/deploy/README.md index 6cf062ad..546904a5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -17,16 +17,15 @@ Leader election is selected for every generated validator with `--leader-mode`, VRF-derived leader each view. Stable mode keeps one round-robin leader for each term and also requires `--leader-term-length` to set the number of views in each term and `--leader-optimistic-views` to cap proposal issuance and vote admission ahead of directly -notarized ancestry. Values above the term length add no window. Stable mode uses standard -threshold crypto with one signature; that signature is duplicated only to retain Alto's fixed-size -certificate framing. Rotating mode computes the additional VRF seed signature needed to select -the next leader. - -Changing an existing network from VRF to standard certificates is not a rolling upgrade: old and -new verifiers intentionally reject each other's certificates even though the fixed-size framing is -unchanged. Start with newly generated network configuration and empty validator/indexer state, and -switch every validator and verifier together. `deploy.sh` generates a new network identity and -infrastructure for this purpose. +notarized ancestry. Values above the term length add no window. Stable mode uses native standard +threshold crypto with one signature per vote and certificate. Rotating mode carries an additional +VRF seed signature used to select the next leader. + +Changing an existing network from VRF or wrapper-framed standard certificates to native standard +certificates is not a rolling upgrade: the untagged wire and persistent certificate layouts have +different fixed sizes. Start with newly generated network configuration and empty validator and +follower certificate state, then switch every validator, indexer, follower, and browser verifier +together. `deploy.sh` generates a new network identity and infrastructure for this purpose. `--block-size` is a `u32` setting for the number of random bytes appended to each proposed block and defaults to `0`. Validators reject only values whose encoded blocks exceed the authenticated diff --git a/explorer/scripts/check-wasm.mjs b/explorer/scripts/check-wasm.mjs index ee0d0f7e..1169c8c3 100644 --- a/explorer/scripts/check-wasm.mjs +++ b/explorer/scripts/check-wasm.mjs @@ -12,6 +12,16 @@ const fromHex = (hex) => Uint8Array.from( hex.match(/../g).map((byte) => Number.parseInt(byte, 16)), ); +const withoutSeedSignature = (encoded) => { + const bytes = fromHex(encoded); + const proposalSize = 39; + const signatureSize = 48; + return Uint8Array.from([ + ...bytes.slice(0, proposalSize + signatureSize), + ...bytes.slice(proposalSize + 2 * signatureSize), + ]); +}; + const identity = fromHex( "90577f9eefba4e54f98aa6cda386718e82863d4989b7282f09446bdb408469dc" + "3dec0097ef3dc9359d88b04d700290e902628bc56fe9295ad92794114a65b2ee" + @@ -55,4 +65,13 @@ const artifacts = [ for (const [name, parse, encoded] of artifacts) { assert.notEqual(parse(identity, fromHex(encoded)), null, `${name} did not verify`); + if (name !== "seed") { + // Standard certificates use the same vote domain as VRF certificates but omit the + // second, 48-byte seed signature. + assert.notEqual( + parse(identity, withoutSeedSignature(encoded), true), + null, + `standard ${name} did not verify`, + ); + } } diff --git a/explorer/src/alto_types/alto_types_bg.wasm b/explorer/src/alto_types/alto_types_bg.wasm index 132bd2789fe2d872e3ce13a7723c8b4505685ea3..fdec96144c91400f575f4a310a6c6d06b11666bd 100644 GIT binary patch delta 56018 zcmeFad3;pW`3F4b+?mNFlVt))2qD0oVM~xz5fo6lxFEQp#eGGk*5HOxTg5U#sG>%V z4E98g8Wkk6Mv^e7sIg)}jT$Rf)QC~3qDGC1mMSgp_j~TWlOe2XfA9OpEAt^|-=Fh5 z&v~Bbocreovp#t;tLBEZgI<*Dr0`3>-=8A<0{=Q2J}LcD_=J?clvF7tV?74`&?kM~ z*ow3fX@*}!g!FiPGWKO!7a?N4U{P#Pa9-@W;HaaXk-@7bUwzG#^Dn&Y%8RCYo|Rd4 z@VA#-dFkE66Y%H7%F|Os zO#7C@S505q59lnkmygHL>iBp@VOgoz6f1 z4;Nl~#l@FQx#Ik*r%b*KUGv-}bCP1G{PBwOubF=3)l)owi%rWY5c6VX83#wpWvzHl zJ}sv;%lG6ba*LcVACsTTI{8=mmHdymU)&<+$_800AC+&2r{z7OO5QEjiiM&PqQF*U;M1CmV7GI*hIkHKiVvDF26>^pMr}#)bD(c0b*2Tu> zUMuc{5~1UF^UvGh}65Yz3UEy4ZmH8FE2g>@hgib+Nu( zXUL^>u}9$4)Wyt#8M3Y}c0Zieb+LkOGvu1On1a(#7t1M}A=lQ$?t-(vE|%H-I@wqk zYwkTU_ICFJWK&)2%svxir}a2MZm5g>1Ku~_-B>rT=M33g7yA>Omb%yva5mS){$S3? z*izTvDUzNNGo!@x$Fh6$iTV4Qv7^EV$kw{pwD1hsRu}sc&epov)W{6EtuD3&&i1<4 zlwLD3O6%JTr|81Z7KUR7_3kg{)W^>6Jwwi|N6%)+vU>DthAgi~k7kJZu^l~5jy=+M zpsc8m{k<>dq&{|1Kg>yetg+v9azTA;ToLA^KDHLl()yTHj5(=~Jp-q%J~pa9=A=Hh z8qS*f*ue*2PU>ST;jFEX4Ic0(xxPNOe85E6SU+#z^|GnH_J)C3Qmn2m8$7&=cqjJ! zG1H@yOlg%GB@wSF&5RI34Wc9>5Ry7Hq$J`uk*EVzvLupf3e&4YH7J&1`b}XM^Oi(1 zO{D5jIVv&C6erY#ay~QD4f#qUS*D>wQE9cHnl#hrB(``;B0)3D36-PLfSG28P{gpi zfeJ!qz{%5wrqa=0c8dM>Gmq~7ar<_o`%_HU|->%3Y3|98`$3V z|IR(#|Bj~bt-+)jB&_Y%{@>S(>~Dj+8PMMMf5(P@>eP2E-!YsPBK=945;wz7-DdlN z>2}+Wboc+yMX_#&_k}myd>!5BPo2{JU2C_Yw7mmk`~Kh00{gvPcLQv@S%SUn|KQ$x zzTX{j2ew)Xusbp(ktKzCwpa{P`>)o~oU9(;}qBM&3V8 zmadA;zJILTULRX`|H$;Mwa?*v=`n=`kwwM>y?f7D^&C(9CuHCln}L(C9gKL6Mug{g zmYi;FUKu;}fkp|Z*MpC;o|hjy#i>Wy^%Pe{7=J?5hytg&i1d!J%IvzrLXNSJ{6JOS zf!ph!^WYTgy?VIBXska@v+NETVQ;z!>X?YMz3Ib zT8<5VxQNqm=EJM$bbjPwiOB07S;f%M1v64OdF)T^g5xI57wIyno7eM;Q3=zqjBBSy zywi@xHWB`@ZPjOGg!x2_6#dZ)Gkp#Ho zx*1DYYTl|#%-pL zIjzzq+ z-9)w;1{OFebQ9TDGqBW2p_|Coz(8GH)NZIQIo}#P$xd0%0AtvnwaI}d1{lMX#^k_8 z1{lMX4atEP1{lMXX1LKrXbY1V#iY&2fi?yh!<5$Kz%~XL!<4Pbfl};M9mABl3~YC@ z(M_b3AH$?M_``g36Zt9_D05QiCbCsAFyBd`Yn8#vlF13 z$hM7vRwspSA{#*#8z8HMo7-arrbeV5WpaVJ%;U9+ra}NHVwH=-vT{}I*~JI;B2-)a z!@YoNup7j9?gn_hC1C@=9LrfU3Nqr@$xDWG-L)-4&)cfly-NnL&h<4p9 z{2OLj;FnL9PR(-m`7s#~HUg2xCNH~?jrlt^=F^SIc4P+8qi)QX)R-@95Si(yh;SN% z;FFDYSzer{XWwJ`rbaYYVB0`6vFw`uvC`$<-Yy04$T668QB`{CBSCqLyUK^w_2qnSGd*fP!A009>obrF-0AT;Iylbus_rz2uyAu>&4IK+atk zI|EMHy4auLl&_1egfo9#Y;t|)*f)>o%Zhce^p$5Gvvgg9=U{+|f9wwY%bIOfI}s^v z!~!QG;6_wA5vguO<+|D>E8i9Dz}!0Bfn{~N1Mk2=2U6;F2Zq7X9k}@P|4plQ&(qv) zRIIBlTy>P_GRf?QHKka+0PrxR1k`>=NoZ2-AL1vA&ZX!)rgK_JMDIAv_qy1-t9xL& z)Q)FFcXU_vcouH?I&~P``RmjGty53XU9e7Vq+7jC{p(p#ESIjU z?eXNf9oiX!O@Mapc#7@BmOsU2Vz0o#vb4g%tOVDvjaYv;mFr^T;Z&`Q{SMB8b+Mb_ zRIiIYx<*(3`kFKUFRL$oc9{_I+Ck4v4v4X_@)z^P*xJWl>|=l-e62CpA8l@E@Psy! zB)2Af+Y`RB=iJoFgl}oWwVgs(Z_YfbpJCwygzS}I*%Lnjf4#y)(*6bIKD8>WeZ2>>rDRlwkC)eg}Q zlsdl9gxWja{EtYPq}3{8YUjOG?G=+^qngjc-@i16@ppCeDdON*@V!Bi37`?sv)Dzc zhG9ellrur7Zot`vCdAHuulrGxpyR<$r?Sl~sBXx*XPH@{F*;=obV+u9d?vbpTF{ql zMA-@>RoAZkcC?O)BvSGV|5-vR4=9J&?Mx< z4<(S_gh)H~!26eHumm(oNI_k-`7LuqKev(~ilw2FZ0$~hiy!+1g%pn9vFAS+7@lAT zQ5l;`)oy#Y3_DkC_J_qHHu}q@$ZBvvsrFGtcYX}!S2EHI4b=oSq*WXrJN2vFQ!wv( zJpyQ%XXF5WlpfIK3Zc0a{D>h8&OMZCRHseQu>z?E^>zrMu;mH+ERXu)czJBCx9w(m zFuQ1Q0PF&CY6ToZJ}$OJH6ELSAB!~!gGHklY{NHQ#H8Ad->eMe;JAf8n5lZzQ(1y@ z`QsTA{+l7wqu!alz+tCMjTpVbBoFkgmXJM~`m+u}uD%ve*cX6(@Vg$fzA} zfxC{~_fe1NkP@5|Odz|_5pR(U9i0(C!-0}8n35uyPU4BQqntE9(n4-p5NrgP5mFtm zm3|ojyhwV(J`+wLoPts216)J@7H*TEp90WQ|;F0`@;U%>7Pb~CwBd(-9#Yvmrwho z`G(6PD`0s;n_@3~8cx|Hc`}x<$3N>83w;(8cgG4pJ3=goo%7kB;D6^c3;x2-Zy#GO zh!l#@FK`5K+hk~+_H2<6FIIpjOc8nsE3elg^qx+{__fPLh<*NfK@^9k?SxSbVH;g( z0pp-cb1(%Qpy}i4^ACO4vRHclNe`g|dd%nmKz`>Aq@V*nGsWou7{>%mIQ`g;<2VbY zutP!98@sKwYXOGtI~q)(&y;A7>}$E$p5{Tzv1eNII(tn|=oIv-0*EOcnGlM7(t5zL zjo^Z?d#nIPYYMcC2}jAxbOeAqbS7ZhEYVH_j;_px+?a8A(1*DIXzbuX?5r>Pj_`86 zYEnwD>AYD%%Zo}D6GDL4J+?1^wzLnG%n1Niu^TtW;$I9Xu3LgUH!-&dzxjl&??uND z=GYr+`{Lk~1V;@CdLoF%&HU&eJY%)ZMR$*9SNivWQ&~CKXu; zMeLA9UhKL?urCSGGfZg_*hOT*RGA(Jm%5@z#K+U5$TQ@IdKK}BzH(!|nn1U?UR_PM zrC!}jcXPe^E8Q*i>NC2n^{SH}Zd<(?Nq1|#x)5%3n{E3M>3LE{XRDEn#@n3g%4guS zDL;KWZcQStF%j3Ch-*#6ZBN9NJ?oaOq|dG|5w`}uQyjJ-5!sx`*qVsjo`@@J?;w44 zg^9Q|I!>LL0+Xlh_3``wEWlEZHq$ZpN2`??qDa)Mt1?9%oNqG3i8{%Lq;Z+aNjGPT z2?YyQq51;>i`C#Aq}3!!LPwh!Sm&eF=b56fQ&DQY1l~3vJyAomSa1B;EYV%0A5GFy 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 delta 52697 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 a6QEhpeCS1}C|5qzP4jh3*}0~h?)?qV!)Cnz diff --git a/follower/src/application.rs b/follower/src/application.rs index 183046a2..1d75d7b3 100644 --- a/follower/src/application.rs +++ b/follower/src/application.rs @@ -70,19 +70,19 @@ impl Reporter for Mailbox { } /// A simple application that tracks just tracks the rate of block processing. -pub(crate) struct Application { +pub(crate) struct Application { context: ContextCell, receiver: mailbox::Receiver, throughput: Throughput, tip: Option, - mailbox: MarshalMailbox>, + mailbox: MarshalMailbox>, pruning_depth: Option, } -impl Application { +impl Application { pub(crate) fn new( context: E, - mailbox: MarshalMailbox>, + mailbox: MarshalMailbox>, mailbox_size: NonZeroUsize, pruning_depth: Option, ) -> (Self, Mailbox) { diff --git a/follower/src/archive.rs b/follower/src/archive.rs index c1ef02e3..f56e121b 100644 --- a/follower/src/archive.rs +++ b/follower/src/archive.rs @@ -16,7 +16,7 @@ use alto_types::{Block, Finalization, Scheme}; use commonware_consensus::{marshal, types::Height}; -use commonware_cryptography::{certificate::Verifier as _, sha256::Digest, Digestible}; +use commonware_cryptography::{sha256::Digest, Digestible}; use commonware_runtime::{ buffer::paged::{page_size, CacheRef}, BufferPooler, Clock, Metrics, Storage, @@ -77,13 +77,14 @@ const FREEZER_JOURNAL_TARGET_SIZE: u64 = 1_073_741_824; // 1GB /// /// Returns the two archive wrappers plus the shared page cache (needed by /// marshal for its own prunable internal stores). -pub(crate) async fn init( +pub(crate) async fn init( context: &mut E, - scheme: &Scheme, + scheme: &C, pruning_depth: Option, -) -> (Certificates, Blocks, CacheRef) +) -> (Certificates, Blocks, CacheRef) where E: BufferPooler + Storage + Metrics + Clock, + C: Scheme, { let page_cache = CacheRef::from_pooler(context, PAGE_CACHE_PAGE_SIZE, PAGE_CACHE_CAPACITY); @@ -191,22 +192,26 @@ where /// Wrapper over [immutable::Archive] and [prunable::Archive] for finalization /// certificates. Implements [marshal::store::Certificates]. #[allow(clippy::large_enum_variant)] -pub(crate) enum Certificates { - Immutable(immutable::Archive), - Prunable(prunable::Archive), +pub(crate) enum Certificates { + Immutable(immutable::Archive>), + Prunable(prunable::Archive>), } -impl marshal::store::Certificates for Certificates { +impl marshal::store::Certificates for Certificates +where + E: BufferPooler + Storage + Metrics + Clock, + C: Scheme, +{ type BlockDigest = Digest; type Commitment = Digest; - type Scheme = Scheme; + type Scheme = C; type Error = archive::Error; async fn put( self, height: Height, commitment: Digest, - finalization: Finalization, + finalization: Finalization, ) -> Result { match self { Self::Immutable(a) => Archive::put(a, height.get(), commitment, finalization) @@ -225,7 +230,10 @@ impl marshal::store::Certificates f } } - async fn get(&self, id: Identifier<'_, Digest>) -> Result, Self::Error> { + async fn get( + &self, + id: Identifier<'_, Digest>, + ) -> Result>, Self::Error> { match self { Self::Immutable(a) => Archive::get(a, id).await, Self::Prunable(a) => Archive::get(a, id).await, diff --git a/follower/src/engine.rs b/follower/src/engine.rs index 57a12497..bc13eae4 100644 --- a/follower/src/engine.rs +++ b/follower/src/engine.rs @@ -65,7 +65,7 @@ fn genesis() -> Block { /// trusted source and a [Resolver] to backfill missing /// blocks. #[allow(clippy::type_complexity)] -pub struct Engine +pub struct Engine where E: BufferPooler + commonware_runtime::Clock @@ -76,23 +76,24 @@ where + Storage + Metrics, T: Strategy, + C: Scheme, { context: ContextCell, marshal: MarshalActor< E, Standard, - ConstantProvider, - Certificates, + ConstantProvider, + Certificates, Blocks, FixedEpocher, T, >, pruning_depth: Option, - marshal_mailbox: MarshalMailbox>, + marshal_mailbox: MarshalMailbox>, mailbox_size: NonZeroUsize, } -impl Engine +impl Engine where E: BufferPooler + commonware_runtime::Clock @@ -103,20 +104,17 @@ where + Storage + Metrics, T: Strategy, + C: Scheme, { /// Create a new [Engine]. pub async fn new( mut context: E, - scheme: Scheme, + scheme: C, mailbox_size: NonZeroUsize, max_repair: NonZero, strategy: T, pruning_depth: Option, - ) -> ( - Self, - MarshalMailbox>, - Option, - ) { + ) -> (Self, MarshalMailbox>, Option) { // Initialize the finalized certificate and block archives. Uses // prunable archives when pruning is enabled, immutable otherwise. let (finalizations_by_height, finalized_blocks, page_cache) = @@ -203,7 +201,7 @@ mod tests { async fn start_engine_with_handler( context: commonware_runtime::deterministic::Context, - scheme: Scheme, + scheme: alto_types::VrfScheme, ) -> handler::Handler { let (engine, _, _) = Engine::new( context.child("engine"), diff --git a/follower/src/feeder.rs b/follower/src/feeder.rs index 12d319da..45ce2254 100644 --- a/follower/src/feeder.rs +++ b/follower/src/feeder.rs @@ -32,20 +32,20 @@ pub enum Error { /// This is the sole signature verification point for the WebSocket streaming /// path. The [Source] (client) is constructed without verification to avoid /// redundant checks. -pub struct Feeder { +pub struct Feeder, CS: Scheme> { context: ContextCell, client: C, - scheme: Scheme, - marshal_mailbox: MarshalMailbox>, + scheme: CS, + marshal_mailbox: MarshalMailbox>, } -impl Feeder { +impl, CS: Scheme> Feeder { /// Create a new [Feeder]. pub fn new( context: E, client: C, - scheme: Scheme, - marshal_mailbox: MarshalMailbox>, + scheme: CS, + marshal_mailbox: MarshalMailbox>, ) -> Self { Self { context: ContextCell::new(context), @@ -99,7 +99,7 @@ impl Feeder { /// Seed messages are ignored. Notarization and finalization messages /// have their threshold signatures verified before being reported to /// marshal along with their associated blocks. - async fn handle_message(&mut self, message: Message) -> Result<(), Error> { + async fn handle_message(&mut self, message: Message) -> Result<(), Error> { match message { Message::Seed(seed) => { trace!(view = seed.view().get(), "received seed"); diff --git a/follower/src/main.rs b/follower/src/main.rs index 9c398b26..dbd09000 100644 --- a/follower/src/main.rs +++ b/follower/src/main.rs @@ -1,6 +1,8 @@ use alto_client::consensus::{Message, Payload}; use alto_client::{ClientBuilder, IndexQuery, Query}; -use alto_types::{CertificateMode, Finalized, Identity, Notarized, Scheme, NAMESPACE}; +use alto_types::{ + CertificateMode, Finalized, Identity, Notarized, Scheme, StandardScheme, VrfScheme, NAMESPACE, +}; use clap::{Arg, Command}; use commonware_codec::DecodeExt; use commonware_consensus::types::Height; @@ -54,60 +56,60 @@ pub struct Config { /// Abstraction over the certificate source (HTTP client) used by the /// [feeder::Feeder] and [resolver::Resolver]. #[allow(dead_code)] -pub(crate) trait Source: Clone + Send + Sync + 'static { +pub(crate) trait Source: Clone + Send + Sync + 'static { type Error: std::error::Error + Send + Sync + 'static; /// Check if the source is reachable. fn health(&self) -> impl Future> + Send; /// Fetch a block by digest or index. - fn block(&self, query: Query) -> impl Future> + Send; + fn block(&self, query: Query) -> impl Future, Self::Error>> + Send; /// Fetch a notarized block by view or latest. fn notarized( &self, query: IndexQuery, - ) -> impl Future> + Send; + ) -> impl Future, Self::Error>> + Send; /// Fetch a finalized block by height or latest. fn finalized( &self, query: IndexQuery, - ) -> impl Future> + Send; + ) -> impl Future, Self::Error>> + Send; /// Open a WebSocket stream of certificate messages. fn listen( &self, ) -> impl Future< Output = Result< - impl Stream> + Send + Unpin, + impl Stream, Self::Error>> + Send + Unpin, Self::Error, >, > + Send; } -impl Source for alto_client::Client { +impl Source for alto_client::Client { type Error = alto_client::Error; fn health(&self) -> impl Future> + Send { self.health() } - fn block(&self, query: Query) -> impl Future> + Send { + fn block(&self, query: Query) -> impl Future, Self::Error>> + Send { self.block_get(query) } fn notarized( &self, query: IndexQuery, - ) -> impl Future> + Send { + ) -> impl Future, Self::Error>> + Send { self.notarized_get(query) } fn finalized( &self, query: IndexQuery, - ) -> impl Future> + Send { + ) -> impl Future, Self::Error>> + Send { self.finalized_get(query) } @@ -115,7 +117,7 @@ impl Source for alto_client::Client { &self, ) -> impl Future< Output = Result< - impl Stream> + Send + Unpin, + impl Stream, Self::Error>> + Send + Unpin, Self::Error, >, > + Send { @@ -146,7 +148,7 @@ fn main() { let cfg = tokio::Config::default() .with_tcp_nodelay(Some(true)) .with_worker_threads(config.worker_threads.get()) - .with_storage_directory(PathBuf::from(config.directory)) + .with_storage_directory(PathBuf::from(&config.directory)) .with_catch_panics(false); let executor = tokio::Runner::new(cfg); @@ -173,88 +175,76 @@ fn main() { "starting follower node" ); - // Create scheme and client - // - // The client is created without verification because signatures are - // checked downstream at each ingestion point: - // - // WebSocket path: Feeder::handle_message (feeder.rs) - // Resolver path: marshal::Actor Deliver handler (commonware-consensus) - // Tip check below: explicit finalized.verify call - // - // Any certificate verification failure is treated as fatal and - // intentionally crashes the follower (fail-fast). - let scheme = - Scheme::certificate_verifier(config.certificate_mode, NAMESPACE, identity); - let client = ClientBuilder::new(&config.source, identity, Sequential) - .with_certificate_mode(config.certificate_mode) - .with_verification_disabled() - .build(); - - // Wait for certificate source to be available - while let Err(e) = client.health().await { - warn!(error = ?e, "waiting for certificate source to be available..."); - context.sleep(Duration::from_secs(1)).await; + match config.certificate_mode { + CertificateMode::Standard => run::(context, config, identity).await, + CertificateMode::Vrf => run::(context, config, identity).await, } - info!("connected to certificate source"); - - // Create engine - let strategy = Rayon::new(config.signature_threads).unwrap(); - let (engine, mailbox, last_processed_height) = engine::Engine::new( - context.child("engine"), - scheme.clone(), - config.mailbox_size, - config.max_repair, - strategy, - config.pruning_depth, - ) - .await; + }); +} - // On the first run (no previously synced data), optionally skip to the - // latest finalized height so the follower starts near tip instead of - // backfilling from genesis. - if config.tip && last_processed_height.is_none_or(|height| height == Height::zero()) { - match client.finalized_get(IndexQuery::Latest).await { - Ok(finalized) => { - assert!( - finalized.verify(&scheme, &Sequential), - "failed to verify finalization signature for checkpoint" - ); - let height = finalized.block.height; - info!(height = height.get(), "setting checkpoint floor from latest finalized block"); - mailbox.set_floor(finalized.proof.clone()); - } - Err(e) => { - warn!(error = ?e, "failed to fetch latest finalized block for checkpoint, will backfill from genesis"); - } +async fn run(context: tokio::Context, config: Config, identity: Identity) { + // The client decodes this concrete format but leaves signature checks to the feeder, + // resolver-backed marshal, and checkpoint path so certificates are not verified twice. + let scheme = C::certificate_verifier(NAMESPACE, identity); + let client = ClientBuilder::<_, C>::new_with_scheme( + &config.source, + C::certificate_verifier(NAMESPACE, identity), + Sequential, + ) + .with_verification_disabled() + .build(); + + while let Err(e) = client.health().await { + warn!(error = ?e, "waiting for certificate source to be available..."); + context.sleep(Duration::from_secs(1)).await; + } + info!("connected to certificate source"); + + let strategy = Rayon::new(config.signature_threads).unwrap(); + let (engine, mailbox, last_processed_height) = engine::Engine::new( + context.child("engine"), + scheme.clone(), + config.mailbox_size, + config.max_repair, + strategy, + config.pruning_depth, + ) + .await; + + if config.tip && last_processed_height.is_none_or(|height| height == Height::zero()) { + match client.finalized_get(IndexQuery::Latest).await { + Ok(finalized) => { + assert!( + finalized.verify(&scheme, &Sequential), + "failed to verify finalization signature for checkpoint" + ); + let height = finalized.block.height; + info!( + height = height.get(), + "setting checkpoint floor from latest finalized block" + ); + mailbox.set_floor(finalized.proof.clone()); + } + Err(e) => { + warn!(error = ?e, "failed to fetch latest finalized block for checkpoint, will backfill from genesis"); } } + } - // Create resolver - let marshal_resolver = resolver::init( - context.child("resolver"), - client.clone(), - config.mailbox_size, - Duration::from_millis(config.fetch_retry_timeout_ms), - ); - - // Start engine - let engine_handle = engine.start(marshal_resolver); + let marshal_resolver = resolver::init( + context.child("resolver"), + client.clone(), + config.mailbox_size, + Duration::from_millis(config.fetch_retry_timeout_ms), + ); + let engine_handle = engine.start(marshal_resolver); - // Start certificate feeder - let feeder = feeder::Feeder::new( - context.child("feeder"), - client, - scheme, - mailbox, - ); - let feeder_handle = feeder.start(); + let feeder = feeder::Feeder::new(context.child("feeder"), client, scheme, mailbox); + let feeder_handle = feeder.start(); - // Wait for any task to finish - select! { - _ = engine_handle => {}, - _ = feeder_handle => {}, - }; - error!("follower stopped unexpectedly"); - }); + select! { + _ = engine_handle => {}, + _ = feeder_handle => {}, + }; + error!("follower stopped unexpectedly"); } diff --git a/follower/src/resolver.rs b/follower/src/resolver.rs index 52e6fa08..0f39c20f 100644 --- a/follower/src/resolver.rs +++ b/follower/src/resolver.rs @@ -1,5 +1,6 @@ use crate::Source; use alto_client::{consensus::Payload, IndexQuery, Query}; +use alto_types::Scheme; use bytes::Bytes; use commonware_codec::Encode; use commonware_consensus::{ @@ -9,7 +10,7 @@ use commonware_consensus::{ use commonware_cryptography::{ed25519::PublicKey, sha256::Digest}; use commonware_resolver::opaque; use commonware_runtime::{Clock, Metrics, Spawner}; -use std::{future::Future, num::NonZeroUsize, time::Duration}; +use std::{future::Future, marker::PhantomData, num::NonZeroUsize, time::Duration}; use tracing::{debug, warn}; type Key = handler::Key; @@ -17,7 +18,7 @@ type Subscriber = handler::Annotation; pub type Resolver = opaque::Resolver; /// Start the follower resolver and marshal handler backed by `client`. -pub fn init( +pub fn init( context: E, client: C, mailbox_size: NonZeroUsize, @@ -25,12 +26,13 @@ pub fn init( ) -> (handler::Receiver, Resolver) where E: Clock + Spawner + Metrics, - C: Source, + C: Source, + CS: Scheme, { let (handler_rx, handler) = handler::init(context.child("handler"), mailbox_size); let resolver = opaque::init::<_, _, _, PublicKey>( context.child("resolver"), - Fetcher::new(client), + Fetcher::::new(client), handler, mailbox_size, fetch_retry_timeout, @@ -40,17 +42,18 @@ where /// Fetches and encodes marshal resolver payloads from an Alto source client. #[derive(Clone)] -struct Fetcher(C); +struct Fetcher(C, PhantomData); -impl Fetcher { +impl Fetcher { const fn new(client: C) -> Self { - Self(client) + Self(client, PhantomData) } } -impl opaque::Fetcher for Fetcher +impl opaque::Fetcher for Fetcher where - C: Source, + C: Source, + CS: Scheme, { type Key = Key; type Value = Bytes; @@ -71,9 +74,10 @@ where } } -impl Fetcher +impl Fetcher where - C: Source, + C: Source, + CS: Scheme, { /// Fetch and encode a block response by digest. async fn fetch_block_by_digest(digest: Digest, client: C) -> Option { @@ -141,6 +145,7 @@ mod tests { use super::*; use crate::test_utils::{MockError, MockSource, TestFixture}; use alto_client::Query; + use alto_types::VrfScheme; use commonware_cryptography::{ed25519::PrivateKey, Digestible, Signer}; use commonware_macros::test_traced; use commonware_resolver::{Consumer, Delivery, Resolver as _, TargetedResolver as _}; @@ -230,40 +235,41 @@ mod tests { } } - impl Source for BlockingSource { + impl Source for BlockingSource { type Error = MockError; async fn health(&self) -> Result<(), Self::Error> { Ok(()) } - async fn block(&self, _query: Query) -> Result { + async fn block(&self, _query: Query) -> Result, Self::Error> { if let Some(sender) = self.started.lock().take() { let _ = sender.send(()); } let _drop_signal = DropSignal(self.dropped.clone()); - std::future::pending::>().await + std::future::pending::, Self::Error>>().await } async fn notarized( &self, _query: IndexQuery, - ) -> Result { + ) -> Result, Self::Error> { Err(MockError("notarized not supported".to_string())) } async fn finalized( &self, _query: IndexQuery, - ) -> Result { + ) -> Result, Self::Error> { Err(MockError("finalized not supported".to_string())) } async fn listen( &self, ) -> Result< - impl futures::Stream> - + Send + impl futures::Stream< + Item = Result, Self::Error>, + > + Send + Unpin, Self::Error, > { @@ -271,14 +277,14 @@ mod tests { } } - fn start_resolver( + fn start_resolver>( context: deterministic::Context, source: C, consumer: TestConsumer, ) -> Resolver { opaque::init::<_, _, _, PublicKey>( context, - Fetcher::new(source), + Fetcher::::new(source), consumer, NZUsize!(16), DEFAULT_FETCH_RETRY_TIMEOUT, diff --git a/follower/src/test_utils.rs b/follower/src/test_utils.rs index 59be3787..04647b7a 100644 --- a/follower/src/test_utils.rs +++ b/follower/src/test_utils.rs @@ -3,7 +3,7 @@ use crate::Source; use alto_client::consensus::{Message, Payload}; use alto_client::{IndexQuery, Query}; -use alto_types::{Block, CertificateMode, Context, Finalized, Notarized, Scheme, EPOCH, NAMESPACE}; +use alto_types::{Block, Context, Finalized, Notarized, VrfScheme, EPOCH, NAMESPACE}; use bytes::Bytes; use commonware_consensus::{ simplex::{ @@ -26,18 +26,19 @@ use thiserror::Error; #[error("{0}")] pub struct MockError(pub String); -pub type BlockHandler = Arc Option + Send + Sync>>>>; +pub type BlockHandler = + Arc Option> + Send + Sync>>>>; pub type FinalizedHandler = - Arc Option + Send + Sync>>>>; + Arc Option> + Send + Sync>>>>; pub type NotarizedHandler = - Arc Option + Send + Sync>>>>; + Arc Option> + Send + Sync>>>>; #[derive(Clone)] pub struct MockSource { pub block_handler: BlockHandler, pub finalized_handler: FinalizedHandler, pub notarized_handler: NotarizedHandler, - pub messages: Arc>>, + pub messages: Arc>>>, } impl MockSource { @@ -51,14 +52,14 @@ impl MockSource { } } -impl Source for MockSource { +impl Source for MockSource { type Error = MockError; async fn health(&self) -> Result<(), Self::Error> { Ok(()) } - async fn block(&self, query: Query) -> Result { + async fn block(&self, query: Query) -> Result, Self::Error> { let handler = self.block_handler.clone(); let guard = handler.lock(); match guard.as_ref().and_then(|f| f(query)) { @@ -67,7 +68,7 @@ impl Source for MockSource { } } - async fn notarized(&self, query: IndexQuery) -> Result { + async fn notarized(&self, query: IndexQuery) -> Result, Self::Error> { let handler = self.notarized_handler.clone(); let guard = handler.lock(); match guard.as_ref().and_then(|f| f(query)) { @@ -76,7 +77,7 @@ impl Source for MockSource { } } - async fn finalized(&self, query: IndexQuery) -> Result { + async fn finalized(&self, query: IndexQuery) -> Result, Self::Error> { let handler = self.finalized_handler.clone(); let guard = handler.lock(); match guard.as_ref().and_then(|f| f(query)) { @@ -89,7 +90,7 @@ impl Source for MockSource { &self, ) -> impl Future< Output = Result< - impl futures::Stream> + Send + Unpin, + impl futures::Stream, Self::Error>> + Send + Unpin, Self::Error, >, > + Send { @@ -102,7 +103,7 @@ impl Source for MockSource { } pub struct TestFixture { - pub schemes: Vec, + pub schemes: Vec, } impl TestFixture { @@ -110,7 +111,6 @@ impl TestFixture { let mut rng = StdRng::seed_from_u64(0); let Fixture { schemes, .. } = bls12381_threshold::fixture::(&mut rng, NAMESPACE, 4); - let schemes = schemes.into_iter().map(Scheme::from_vrf).collect(); Self { schemes } } @@ -130,7 +130,7 @@ impl TestFixture { ) } - pub fn create_finalized(&self, height: u64, view: u64) -> Finalized { + pub fn create_finalized(&self, height: u64, view: u64) -> Finalized { let block = self.create_block(height, view); let proposal = Proposal::new( Round::new(EPOCH, View::new(view)), @@ -148,7 +148,7 @@ impl TestFixture { Finalized::new(finalization, block) } - pub fn create_notarized(&self, height: u64, view: u64) -> Notarized { + pub fn create_notarized(&self, height: u64, view: u64) -> Notarized { let block = self.create_block(height, view); let proposal = Proposal::new( Round::new(EPOCH, View::new(view)), @@ -166,16 +166,16 @@ impl TestFixture { Notarized::new(notarization, block) } - pub fn verifier_scheme(&self) -> Scheme { + pub fn verifier_scheme(&self) -> VrfScheme { let identity = *self.schemes[0].identity(); - Scheme::certificate_verifier(CertificateMode::Vrf, NAMESPACE, identity) + VrfScheme::certificate_verifier(NAMESPACE, identity) } - pub fn wrong_verifier_scheme(&self) -> Scheme { + pub fn wrong_verifier_scheme(&self) -> VrfScheme { let mut rng = StdRng::seed_from_u64(42); let Fixture { schemes, .. } = bls12381_threshold::fixture::(&mut rng, NAMESPACE, 4); let wrong_identity = *schemes[0].polynomial().public(); - Scheme::certificate_verifier(CertificateMode::Vrf, NAMESPACE, wrong_identity) + VrfScheme::certificate_verifier(NAMESPACE, wrong_identity) } } diff --git a/indexer/src/lib.rs b/indexer/src/lib.rs index ec2cb36b..4bd003ba 100644 --- a/indexer/src/lib.rs +++ b/indexer/src/lib.rs @@ -21,25 +21,36 @@ use std::{ use tokio::sync::broadcast; use tower_http::cors::CorsLayer; -#[derive(Default)] -pub struct State { +pub struct State { seeds: BTreeMap, - notarizations: BTreeMap, - finalizations: BTreeMap, + notarizations: BTreeMap>, + finalizations: BTreeMap>, finalized_height_to_view: BTreeMap, blocks_by_digest: BTreeMap, } +impl Default for State { + fn default() -> Self { + Self { + seeds: BTreeMap::new(), + notarizations: BTreeMap::new(), + finalizations: BTreeMap::new(), + finalized_height_to_view: BTreeMap::new(), + blocks_by_digest: BTreeMap::new(), + } + } +} + #[derive(Clone)] -pub struct Indexer { - scheme: Scheme, - state: Arc>, +pub struct Indexer { + scheme: C, + state: Arc>>, consensus_tx: broadcast::Sender>, strategy: S, } -impl Indexer { - pub fn new(scheme: Scheme, strategy: S) -> Self { +impl Indexer { + pub fn new(scheme: C, strategy: S) -> Self { let (consensus_tx, _) = broadcast::channel(1024); let state = Arc::new(RwLock::new(State::default())); @@ -82,7 +93,7 @@ impl Indexer { } } - pub fn submit_notarization(&self, notarized: Notarized) -> Result<(), &'static str> { + pub fn submit_notarization(&self, notarized: Notarized) -> Result<(), &'static str> { // Verify signature with identity if !notarized.verify(&self.scheme, &self.strategy) { return Err("Invalid notarization signature"); @@ -113,7 +124,7 @@ impl Indexer { Ok(()) } - pub fn get_notarization(&self, query: &str) -> Option { + pub fn get_notarization(&self, query: &str) -> Option> { let state = self.state.read().unwrap(); if query == LATEST { state.notarizations.last_key_value().map(|(_, n)| n.clone()) @@ -125,7 +136,7 @@ impl Indexer { } } - pub fn submit_finalization(&self, finalized: Finalized) -> Result<(), &'static str> { + pub fn submit_finalization(&self, finalized: Finalized) -> Result<(), &'static str> { // Verify signature with identity if !finalized.verify(&self.scheme, &self.strategy) { return Err("Invalid finalization signature"); @@ -159,7 +170,7 @@ impl Indexer { Ok(()) } - pub fn get_finalization(&self, query: &str) -> Option { + pub fn get_finalization(&self, query: &str) -> Option> { let state = self.state.read().unwrap(); if query == LATEST { state.finalizations.last_key_value().map(|(_, f)| f.clone()) @@ -171,7 +182,7 @@ impl Indexer { } } - pub fn get_block(&self, query: &str) -> Option { + pub fn get_block(&self, query: &str) -> Option> { let state = self.state.read().unwrap(); if query == LATEST { @@ -217,17 +228,17 @@ impl Indexer { } #[allow(clippy::large_enum_variant)] -pub enum BlockResult { +pub enum BlockResult { Block(Block), - Finalized(Finalized), + Finalized(Finalized), } -pub struct Api { - indexer: Arc>, +pub struct Api { + indexer: Arc>, } -impl Api { - pub fn new(indexer: Arc>) -> Self { +impl Api { + pub fn new(indexer: Arc>) -> Self { Self { indexer } } @@ -252,8 +263,8 @@ async fn health_check() -> impl IntoResponse { (StatusCode::OK, "ok") } -async fn seed_upload( - AxumState(indexer): AxumState>>, +async fn seed_upload( + AxumState(indexer): AxumState>>, body: Bytes, ) -> impl IntoResponse { match Seed::decode(&mut body.as_ref()) { @@ -265,8 +276,8 @@ async fn seed_upload( } } -async fn seed_get( - AxumState(indexer): AxumState>>, +async fn seed_get( + AxumState(indexer): AxumState>>, Path(query): Path, ) -> impl IntoResponse { match indexer.get_seed(&query) { @@ -275,11 +286,11 @@ async fn seed_get( } } -async fn notarization_upload( - AxumState(indexer): AxumState>>, +async fn notarization_upload( + AxumState(indexer): AxumState>>, body: Bytes, ) -> impl IntoResponse { - match Notarized::decode(&mut body.as_ref()) { + match Notarized::::decode(&mut body.as_ref()) { Ok(notarized) => match indexer.submit_notarization(notarized) { Ok(_) => StatusCode::OK, Err(_) => StatusCode::UNAUTHORIZED, @@ -288,8 +299,8 @@ async fn notarization_upload( } } -async fn notarization_get( - AxumState(indexer): AxumState>>, +async fn notarization_get( + AxumState(indexer): AxumState>>, Path(query): Path, ) -> impl IntoResponse { match indexer.get_notarization(&query) { @@ -298,11 +309,11 @@ async fn notarization_get( } } -async fn finalization_upload( - AxumState(indexer): AxumState>>, +async fn finalization_upload( + AxumState(indexer): AxumState>>, body: Bytes, ) -> impl IntoResponse { - match Finalized::decode(&mut body.as_ref()) { + match Finalized::::decode(&mut body.as_ref()) { Ok(finalized) => match indexer.submit_finalization(finalized) { Ok(_) => StatusCode::OK, Err(_) => StatusCode::UNAUTHORIZED, @@ -311,8 +322,8 @@ async fn finalization_upload( } } -async fn finalization_get( - AxumState(indexer): AxumState>>, +async fn finalization_get( + AxumState(indexer): AxumState>>, Path(query): Path, ) -> impl IntoResponse { match indexer.get_finalization(&query) { @@ -321,8 +332,8 @@ async fn finalization_get( } } -async fn block_upload( - AxumState(indexer): AxumState>>, +async fn block_upload( + AxumState(indexer): AxumState>>, body: Bytes, ) -> impl IntoResponse { match Block::decode(&mut body.as_ref()) { @@ -334,8 +345,8 @@ async fn block_upload( } } -async fn block_get( - AxumState(indexer): AxumState>>, +async fn block_get( + AxumState(indexer): AxumState>>, Path(query): Path, ) -> impl IntoResponse { match indexer.get_block(&query) { @@ -349,16 +360,16 @@ async fn block_get( } } -async fn consensus_ws( - AxumState(indexer): AxumState>>, +async fn consensus_ws( + AxumState(indexer): AxumState>>, ws: WebSocketUpgrade, ) -> impl IntoResponse { ws.on_upgrade(move |socket| handle_consensus_ws(socket, indexer)) } -async fn handle_consensus_ws( +async fn handle_consensus_ws( socket: axum::extract::ws::WebSocket, - indexer: Arc>, + indexer: Arc>, ) { let (mut sender, _receiver) = socket.split(); let mut consensus = indexer.consensus_subscriber(); @@ -378,10 +389,10 @@ async fn handle_consensus_ws( mod tests { use super::*; use alto_client::{Client, ClientBuilder, IndexQuery, Query}; - use alto_types::{Context, Identity, Seedable, EPOCH, NAMESPACE}; + use alto_types::{Context, Identity, Seedable, StandardScheme, VrfScheme, EPOCH, NAMESPACE}; use commonware_consensus::{ simplex::{ - scheme::bls12381_threshold::vrf as bls12381_threshold, + scheme::bls12381_threshold::{standard, vrf as bls12381_threshold}, types::{Finalization, Finalize, Notarization, Notarize, Proposal}, }, types::{Height, Round, View}, @@ -403,7 +414,7 @@ mod tests { /// Test context containing common setup for indexer tests. struct TestContext { - schemes: Vec, + schemes: Vec, client: Client, } @@ -452,24 +463,24 @@ mod tests { } /// Create a notarized block. - fn notarized(&self) -> Notarized { + fn notarized(&self) -> Notarized { let block = self.test_block(); let proposal = self.proposal(&block); Notarized::new(create_notarization(&self.schemes, proposal), block) } /// Create a finalized block. - fn finalized(&self) -> Finalized { + fn finalized(&self) -> Finalized { let block = self.test_block(); let proposal = self.proposal(&block); Finalized::new(create_finalization(&self.schemes, proposal), block) } } - fn create_notarization( - schemes: &[Scheme], + fn create_notarization( + schemes: &[C], proposal: Proposal, - ) -> alto_types::Notarization { + ) -> alto_types::Notarization { let notarizes: Vec<_> = schemes .iter() .map(|scheme| Notarize::sign(scheme, proposal.clone()).unwrap()) @@ -477,10 +488,10 @@ mod tests { Notarization::from_notarizes(&schemes[0], ¬arizes, &Sequential).unwrap() } - fn create_finalization( - schemes: &[Scheme], + fn create_finalization( + schemes: &[C], proposal: Proposal, - ) -> alto_types::Finalization { + ) -> alto_types::Finalization { let finalizes: Vec<_> = schemes .iter() .map(|scheme| Finalize::sign(scheme, proposal.clone()).unwrap()) @@ -488,8 +499,8 @@ mod tests { Finalization::from_finalizes(&schemes[0], &finalizes, &Sequential).unwrap() } - async fn start_server( - scheme: Scheme, + async fn start_server( + scheme: C, strategy: impl Strategy, ) -> (SocketAddr, tokio::task::JoinHandle<()>) { let indexer = Arc::new(Indexer::new(scheme, strategy)); @@ -506,7 +517,7 @@ mod tests { (addr, handle) } - async fn wait_for_ready(client: &Client) { + async fn wait_for_ready(client: &Client) { loop { if client.health().await.is_ok() { return; @@ -515,12 +526,11 @@ mod tests { } } - fn fixture(seed: u64) -> (Vec, Identity) { + fn fixture(seed: u64) -> (Vec, Identity) { let mut rng = StdRng::seed_from_u64(seed); let Fixture { schemes, .. } = bls12381_threshold::fixture::(&mut rng, NAMESPACE, 4); - let identity = *schemes[0].polynomial().public(); - let schemes = schemes.into_iter().map(Scheme::from_vrf).collect(); + let identity = *schemes[0].identity(); (schemes, identity) } @@ -574,6 +584,66 @@ mod tests { assert_eq!(retrieved.proof.view().get(), 1); } + #[tokio::test] + async fn test_standard_certificate_operations() { + let mut rng = StdRng::seed_from_u64(2); + let Fixture { schemes, .. } = standard::fixture::(&mut rng, NAMESPACE, 4); + let identity = *schemes[0].identity(); + let (addr, _handle) = start_server(schemes[0].clone(), Sequential).await; + let client = Client::::new_standard( + &format!("http://{addr}"), + identity, + Sequential, + ); + wait_for_ready(&client).await; + + let context = Context { + round: Round::new(EPOCH, View::new(1)), + leader: ed25519::PrivateKey::from_seed(0).public_key(), + parent: (View::new(0), sha256::Digest::EMPTY), + }; + let block = Block::new( + context, + Sha256::hash(&[b"genesis"]), + Height::new(1), + 1000, + Bytes::new(), + ); + let proposal = Proposal::new( + Round::new(EPOCH, View::new(1)), + View::new(0), + block.digest(), + ); + let notarized = Notarized::new( + create_notarization(&schemes, proposal.clone()), + block.clone(), + ); + let finalized = Finalized::new(create_finalization(&schemes, proposal), block); + + client.notarized_upload(notarized).await.unwrap(); + client.finalized_upload(finalized).await.unwrap(); + assert_eq!( + client + .notarized_get(IndexQuery::Latest) + .await + .unwrap() + .proof + .view() + .get(), + 1 + ); + assert_eq!( + client + .finalized_get(IndexQuery::Latest) + .await + .unwrap() + .proof + .view() + .get(), + 1 + ); + } + #[tokio::test] async fn test_block_retrieval() { let ctx = TestContext::new().await; @@ -724,7 +794,7 @@ mod tests { } async fn start_tls_server( - scheme: Scheme, + scheme: VrfScheme, cert_key: &CertifiedKey, strategy: impl Strategy, ) -> (SocketAddr, tokio::task::JoinHandle<()>) { diff --git a/indexer/src/main.rs b/indexer/src/main.rs index aa723587..235a368a 100644 --- a/indexer/src/main.rs +++ b/indexer/src/main.rs @@ -1,5 +1,5 @@ use alto_indexer::{Api, Indexer}; -use alto_types::{CertificateMode, Identity, Scheme, NAMESPACE}; +use alto_types::{CertificateMode, Identity, Scheme, StandardScheme, VrfScheme, NAMESPACE}; use axum::{ body::{Body, Bytes}, extract::Extension, @@ -212,9 +212,20 @@ async fn main() -> Result<(), Box> { let identity: Identity = Identity::decode(&mut bytes.as_slice()).map_err(|_| "Failed to decode identity")?; - // Initialize indexer - let certificate_verifier = - Scheme::certificate_verifier(settings.certificate_mode, NAMESPACE, identity); + match settings.certificate_mode { + CertificateMode::Standard => { + serve::(settings, identity, explorer_script).await + } + CertificateMode::Vrf => serve::(settings, identity, explorer_script).await, + } +} + +async fn serve( + settings: Settings, + identity: Identity, + explorer_script: ExplorerScript, +) -> Result<(), Box> { + let certificate_verifier = C::certificate_verifier(NAMESPACE, identity); let indexer = Arc::new(Indexer::new(certificate_verifier, Sequential)); let app = Api::new(indexer) .router() diff --git a/inspector/src/main.rs b/inspector/src/main.rs index e6e52a5e..3d89d8d3 100644 --- a/inspector/src/main.rs +++ b/inspector/src/main.rs @@ -78,10 +78,10 @@ use alto_client::{ consensus::{Message, Payload}, - Client, IndexQuery, Query, + ClientBuilder, IndexQuery, Query, }; -use alto_types::{CertificateMode, Identity}; -use clap::{value_parser, Arg, Command}; +use alto_types::{CertificateMode, Identity, Scheme, StandardScheme, VrfScheme, NAMESPACE}; +use clap::{value_parser, Arg, ArgMatches, Command}; use commonware_codec::DecodeExt; use commonware_formatting::from_hex; use commonware_parallel::Sequential; @@ -193,13 +193,24 @@ async fn main() { _ => unreachable!("clap validates certificate mode"), }; + match certificate_mode { + CertificateMode::Standard => run::(&matches).await, + CertificateMode::Vrf => run::(&matches).await, + } +} + +async fn run(matches: &ArgMatches) { if let Some(matches) = matches.subcommand_matches("listen") { let indexer = matches.get_one::("indexer").unwrap(); let identity = matches.get_one::("identity").unwrap(); let identity = from_hex(identity).expect("Failed to decode identity"); let identity = Identity::decode(identity.as_ref()).expect("Invalid identity"); - let client = - Client::new_with_certificate_mode(indexer, identity, certificate_mode, Sequential); + let client = ClientBuilder::<_, C>::new_with_scheme( + indexer, + C::certificate_verifier(NAMESPACE, identity), + Sequential, + ) + .build(); let mut stream = client.listen().await.expect("Failed to connect to indexer"); info!("listening for consensus messages..."); @@ -218,8 +229,12 @@ async fn main() { let identity = matches.get_one::("identity").unwrap(); let identity = from_hex(identity).expect("Failed to decode identity"); let identity = Identity::decode(identity.as_ref()).expect("Invalid identity"); - let client = - Client::new_with_certificate_mode(indexer, identity, certificate_mode, Sequential); + let client = ClientBuilder::<_, C>::new_with_scheme( + indexer, + C::certificate_verifier(NAMESPACE, identity), + Sequential, + ) + .build(); let prepare_flag = matches.get_flag("prepare"); if prepare_flag { diff --git a/inspector/src/utils.rs b/inspector/src/utils.rs index 836b7aea..a426982e 100644 --- a/inspector/src/utils.rs +++ b/inspector/src/utils.rs @@ -1,5 +1,5 @@ use alto_client::{IndexQuery, Query}; -use alto_types::{Finalized, Notarized, Seed}; +use alto_types::{Finalized, Notarized, Scheme, Seed}; use commonware_codec::DecodeExt; use commonware_consensus::Viewable; use commonware_cryptography::{sha256::Digest, Digestible}; @@ -89,7 +89,7 @@ pub fn log_seed(seed: Seed) { info!(view = %seed.view(), signature = ?seed.signature, "seed"); } -pub fn log_notarization(notarized: Notarized) { +pub fn log_notarization(notarized: Notarized) { let now = time::SystemTime::now().epoch_millis(); let age_ms = now.saturating_sub(notarized.block.timestamp); let age_str = format_age(age_ms); @@ -103,7 +103,7 @@ pub fn log_notarization(notarized: Notarized) { ); } -pub fn log_finalization(finalized: Finalized) { +pub fn log_finalization(finalized: Finalized) { let now = time::SystemTime::now().epoch_millis(); let age_ms = now.saturating_sub(finalized.block.timestamp); let age_str = format_age(age_ms); diff --git a/types/src/block.rs b/types/src/block.rs index c2ecdc4c..58365026 100644 --- a/types/src/block.rs +++ b/types/src/block.rs @@ -137,23 +137,31 @@ impl Digestible for Block { } } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Notarized { - pub proof: Notarization, +#[derive(Clone, Debug)] +pub struct Notarized { + pub proof: Notarization, pub block: Block, } -impl Notarized { - pub fn new(proof: Notarization, block: Block) -> Self { +impl PartialEq for Notarized { + fn eq(&self, other: &Self) -> bool { + self.proof == other.proof && self.block == other.block + } +} + +impl Eq for Notarized {} + +impl Notarized { + pub fn new(proof: Notarization, block: Block) -> Self { Self { proof, block } } - pub fn verify(&self, scheme: &Scheme, strategy: &impl Strategy) -> bool { + pub fn verify(&self, scheme: &S, strategy: &impl Strategy) -> bool { self.proof.verify(&mut sys_rng(), scheme, strategy) } } -impl Write for Notarized { +impl Write for Notarized { fn write(&self, buf: &mut impl BufMut) { self.proof.write(buf); self.block.write(buf); @@ -165,11 +173,11 @@ impl Write for Notarized { } } -impl Read for Notarized { +impl Read for Notarized { type Cfg = (); fn read_cfg(buf: &mut impl Buf, _: &Self::Cfg) -> Result { - let proof = Notarization::read(buf)?; + let proof = Notarization::::read(buf)?; let block = Block::read(buf)?; // Ensure the proof is for the block @@ -183,7 +191,7 @@ impl Read for Notarized { } } -impl EncodeSize for Notarized { +impl EncodeSize for Notarized { fn encode_size(&self) -> usize { self.proof.encode_size() + self.block.encode_size() } @@ -193,23 +201,31 @@ impl EncodeSize for Notarized { } } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Finalized { - pub proof: Finalization, +#[derive(Clone, Debug)] +pub struct Finalized { + pub proof: Finalization, pub block: Block, } -impl Finalized { - pub fn new(proof: Finalization, block: Block) -> Self { +impl PartialEq for Finalized { + fn eq(&self, other: &Self) -> bool { + self.proof == other.proof && self.block == other.block + } +} + +impl Eq for Finalized {} + +impl Finalized { + pub fn new(proof: Finalization, block: Block) -> Self { Self { proof, block } } - pub fn verify(&self, scheme: &Scheme, strategy: &impl Strategy) -> bool { + pub fn verify(&self, scheme: &S, strategy: &impl Strategy) -> bool { self.proof.verify(&mut sys_rng(), scheme, strategy) } } -impl Write for Finalized { +impl Write for Finalized { fn write(&self, buf: &mut impl BufMut) { self.proof.write(buf); self.block.write(buf); @@ -221,11 +237,11 @@ impl Write for Finalized { } } -impl Read for Finalized { +impl Read for Finalized { type Cfg = (); fn read_cfg(buf: &mut impl Buf, _: &Self::Cfg) -> Result { - let proof = Finalization::read(buf)?; + let proof = Finalization::::read(buf)?; let block = Block::read(buf)?; // Ensure the proof is for the block @@ -239,7 +255,7 @@ impl Read for Finalized { } } -impl EncodeSize for Finalized { +impl EncodeSize for Finalized { fn encode_size(&self) -> usize { self.proof.encode_size() + self.block.encode_size() } diff --git a/types/src/consensus.rs b/types/src/consensus.rs index 807f0120..6511c005 100644 --- a/types/src/consensus.rs +++ b/types/src/consensus.rs @@ -1,34 +1,32 @@ +use commonware_codec::Read; use commonware_consensus::simplex::{ - scheme::bls12381_threshold::{standard, vrf}, + scheme::{ + bls12381_threshold::{standard, vrf}, + Scheme as SimplexScheme, + }, types::{ Activity as CActivity, Context as CContext, Finalization as CFinalization, - Notarization as CNotarization, Subject, + Notarization as CNotarization, }, }; use commonware_cryptography::{ - bls12381::{ - certificate::threshold::Certificate as StandardCertificate, - primitives::{ - group::Share, - sharing::Sharing, - variant::{MinSig, Variant}, - }, - }, - certificate::{ - Attestation, Scheme as CertificateScheme, Verification, Verifier as CertificateVerifier, + bls12381::primitives::{ + group::Share, + sharing::Sharing, + variant::{MinSig, Variant}, }, + certificate::Verifier as CertificateVerifier, ed25519, sha256::Digest, }; -use commonware_parallel::Strategy; -use commonware_utils::{ordered::Set, N3f1, Participant}; -use rand::CryptoRng; +use commonware_utils::ordered::Set; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; -type StandardScheme = standard::Scheme; -type VrfScheme = vrf::Scheme; -type ConsensusSignature = vrf::Signature; +/// Native one-signature threshold certificates used by stable leaders. +pub type StandardScheme = standard::Scheme; + +/// Vote-and-seed threshold certificates used by rotating leaders. +pub type VrfScheme = vrf::Scheme; /// Certificate construction used by a consensus network. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -40,387 +38,126 @@ pub enum CertificateMode { Vrf, } -#[derive(Clone, Debug)] -enum Inner { - Standard(StandardScheme), - Vrf(VrfScheme), -} - -/// Alto's mode-aware threshold certificate scheme. +/// Alto's common surface over its two statically selected consensus schemes. /// -/// Consensus framing retains the existing fixed-size vote/seed pair. Standard certificates put -/// the vote signature in both fields; mode-aware assembly and verification require them to match, -/// while cryptographic verification and recovery delegate to Commonware's one-signature standard -/// threshold scheme. -#[derive(Clone, Debug)] -pub struct Scheme(Inner); - -impl Scheme { - pub fn signer( - mode: CertificateMode, +/// Consensus engines remain monomorphized over the concrete implementation. This trait only +/// centralizes construction and optional seed extraction used by shared Alto components. +pub trait Scheme: + SimplexScheme + + CertificateVerifier> + + Sized +{ + fn signer( namespace: &[u8], participants: Set, polynomial: Sharing, share: Share, - ) -> Option { - match mode { - CertificateMode::Standard => { - StandardScheme::signer(namespace, participants, polynomial, share) - .map(Inner::Standard) - } - CertificateMode::Vrf => { - VrfScheme::signer(namespace, participants, polynomial, share).map(Inner::Vrf) - } - } - .map(Self) - } - - pub fn verifier( - mode: CertificateMode, - namespace: &[u8], - participants: Set, - polynomial: Sharing, - ) -> Self { - match mode { - CertificateMode::Standard => Self(Inner::Standard(StandardScheme::verifier( - namespace, - participants, - polynomial, - ))), - CertificateMode::Vrf => Self(Inner::Vrf(VrfScheme::verifier( - namespace, - participants, - polynomial, - ))), - } - } - - pub fn certificate_verifier( - mode: CertificateMode, - namespace: &[u8], - identity: Identity, - ) -> Self { - match mode { - CertificateMode::Standard => Self(Inner::Standard( - StandardScheme::certificate_verifier(namespace, identity), - )), - CertificateMode::Vrf => Self(Inner::Vrf(VrfScheme::certificate_verifier( - namespace, identity, - ))), - } - } + ) -> Option; - #[doc(hidden)] - pub fn from_vrf(inner: VrfScheme) -> Self { - Self(Inner::Vrf(inner)) - } + fn certificate_verifier(namespace: &[u8], identity: Identity) -> Self; - pub fn identity(&self) -> &Identity { - match &self.0 { - Inner::Standard(scheme) => scheme.identity(), - Inner::Vrf(scheme) => scheme.identity(), - } - } + fn verify_seed(&self, seed: &Seed) -> bool; - pub fn verify_seed(&self, seed: &Seed) -> bool { - match &self.0 { - Inner::Standard(_) => false, - Inner::Vrf(scheme) => seed.verify(scheme), - } - } - - /// Extracts the round-seed signature after a caller has verified a VRF certificate. - /// - /// This checks the mode's canonical framing but does not authenticate the certificate. - pub fn verified_vrf_seed_signature( + fn vote_signature( certificate: &::Certificate, - ) -> Option { - let signature = certificate.get()?; - (signature.vote_signature != signature.seed_signature).then_some(signature.seed_signature) - } -} - -fn standard_pair(signature: Signature) -> ConsensusSignature { - ConsensusSignature { - vote_signature: signature, - seed_signature: signature, - } -} + ) -> Option; -fn standard_signature(signature: &ConsensusSignature) -> Option { - (signature.vote_signature == signature.seed_signature).then_some(signature.vote_signature) + fn seed_signature( + certificate: &::Certificate, + ) -> Option; } -fn wrap_vrf_attestation(attestation: Attestation) -> Attestation { - Attestation { - signer: attestation.signer, - signature: attestation.signature, +impl Scheme for StandardScheme { + fn signer( + namespace: &[u8], + participants: Set, + polynomial: Sharing, + share: Share, + ) -> Option { + standard::Scheme::::signer(namespace, participants, polynomial, share) } -} -fn wrap_standard_attestation(attestation: Attestation) -> Attestation { - let signature = *attestation - .signature - .get() - .expect("locally constructed standard signature must decode"); - Attestation { - signer: attestation.signer, - signature: standard_pair(signature).into(), + fn certificate_verifier(namespace: &[u8], identity: Identity) -> Self { + standard::Scheme::::certificate_verifier(namespace, identity) } -} -impl CertificateVerifier for Scheme { - type Subject<'a, D: commonware_cryptography::Digest> = Subject<'a, D>; - type Faults = N3f1; - type PublicKey = PublicKey; - type Certificate = vrf::Certificate; - - fn verify_certificate( - &self, - rng: &mut R, - subject: Subject<'_, D>, - certificate: &Self::Certificate, - strategy: &impl Strategy, - ) -> bool - where - R: CryptoRng, - D: commonware_cryptography::Digest, - { - match &self.0 { - Inner::Standard(scheme) => { - let Some(signature) = certificate.get().and_then(standard_signature) else { - return false; - }; - let certificate = StandardCertificate::new(signature); - scheme.verify_certificate(rng, subject, &certificate, strategy) - } - Inner::Vrf(scheme) => scheme.verify_certificate(rng, subject, certificate, strategy), - } + fn verify_seed(&self, _seed: &Seed) -> bool { + false } - fn verify_certificates<'a, R, D, I>( - &self, - rng: &mut R, - certificates: I, - strategy: &impl Strategy, - ) -> bool - where - R: CryptoRng, - D: commonware_cryptography::Digest, - I: Iterator, &'a Self::Certificate)>, - { - match &self.0 { - Inner::Standard(scheme) => { - let Some(certificates) = certificates - .map(|(subject, certificate)| { - let signature = certificate.get().and_then(standard_signature)?; - Some((subject, StandardCertificate::new(signature))) - }) - .collect::>>() - else { - return false; - }; - scheme.verify_certificates( - rng, - certificates - .iter() - .map(|(subject, certificate)| (*subject, certificate)), - strategy, - ) - } - Inner::Vrf(scheme) => scheme.verify_certificates(rng, certificates, strategy), - } + fn vote_signature( + certificate: &::Certificate, + ) -> Option { + certificate.get().copied() } - fn is_batchable() -> bool { - true + fn seed_signature( + _certificate: &::Certificate, + ) -> Option { + None } - - fn certificate_codec_config(&self) {} - - fn certificate_codec_config_unbounded() {} } -impl CertificateScheme for Scheme { - type Signature = ConsensusSignature; - - fn me(&self) -> Option { - match &self.0 { - Inner::Standard(scheme) => scheme.me(), - Inner::Vrf(scheme) => scheme.me(), - } - } - - fn participants(&self) -> &Set { - match &self.0 { - Inner::Standard(scheme) => scheme.participants(), - Inner::Vrf(scheme) => scheme.participants(), - } - } - - fn sign( - &self, - subject: Subject<'_, D>, - ) -> Option> { - match &self.0 { - Inner::Standard(scheme) => scheme.sign(subject).map(wrap_standard_attestation), - Inner::Vrf(scheme) => scheme.sign(subject).map(wrap_vrf_attestation), - } +impl Scheme for VrfScheme { + fn signer( + namespace: &[u8], + participants: Set, + polynomial: Sharing, + share: Share, + ) -> Option { + vrf::Scheme::::signer(namespace, participants, polynomial, share) } - fn verify_attestation( - &self, - rng: &mut R, - subject: Subject<'_, D>, - attestation: &Attestation, - strategy: &impl Strategy, - ) -> bool - where - R: CryptoRng, - D: commonware_cryptography::Digest, - { - match &self.0 { - Inner::Standard(scheme) => { - let Some(signature) = attestation.signature.get().and_then(standard_signature) - else { - return false; - }; - let attestation = Attestation:: { - signer: attestation.signer, - signature: signature.into(), - }; - scheme.verify_attestation(rng, subject, &attestation, strategy) - } - Inner::Vrf(scheme) => { - let attestation = Attestation:: { - signer: attestation.signer, - signature: attestation.signature.clone(), - }; - scheme.verify_attestation(rng, subject, &attestation, strategy) - } - } + fn certificate_verifier(namespace: &[u8], identity: Identity) -> Self { + vrf::Scheme::::certificate_verifier(namespace, identity) } - fn verify_attestations( - &self, - rng: &mut R, - subject: Subject<'_, D>, - attestations: I, - strategy: &impl Strategy, - ) -> Verification - where - R: CryptoRng, - D: commonware_cryptography::Digest, - I: IntoIterator>, - I::IntoIter: Send, - { - match &self.0 { - Inner::Standard(scheme) => { - let (attestations, rejected) = - strategy.map_partition_collect_vec(attestations.into_iter(), |attestation| { - let signer = attestation.signer; - let converted = attestation - .signature - .get() - .and_then(standard_signature) - .map(|signature| Attestation:: { - signer, - signature: signature.into(), - }); - (signer, converted) - }); - let verification = scheme.verify_attestations(rng, subject, attestations, strategy); - let mut rejected: BTreeSet<_> = rejected.into_iter().collect(); - rejected.extend(verification.invalid); - Verification::new( - verification - .verified - .into_iter() - .map(wrap_standard_attestation) - .collect(), - rejected.into_iter().collect(), - ) - } - Inner::Vrf(scheme) => { - let attestations = attestations.into_iter().map(|attestation| Attestation { - signer: attestation.signer, - signature: attestation.signature, - }); - let verification = scheme.verify_attestations(rng, subject, attestations, strategy); - Verification::new( - verification - .verified - .into_iter() - .map(wrap_vrf_attestation) - .collect(), - verification.invalid, - ) - } - } + fn verify_seed(&self, seed: &Seed) -> bool { + seed.verify(self) } - fn assemble(&self, attestations: I, strategy: &impl Strategy) -> Option - where - I: IntoIterator>, - I::IntoIter: Send, - { - match &self.0 { - Inner::Standard(scheme) => { - let attestations = attestations - .into_iter() - .map(|attestation| { - let signature = attestation.signature.get().and_then(standard_signature)?; - Some(Attestation:: { - signer: attestation.signer, - signature: signature.into(), - }) - }) - .collect::>>()?; - let certificate = scheme.assemble(attestations, strategy)?; - let signature = *certificate.get()?; - Some(standard_pair(signature).into()) - } - Inner::Vrf(scheme) => { - let attestations = attestations.into_iter().map(|attestation| Attestation { - signer: attestation.signer, - signature: attestation.signature, - }); - scheme.assemble(attestations, strategy) - } - } + fn vote_signature( + certificate: &::Certificate, + ) -> Option { + certificate + .get() + .map(|certificate| certificate.vote_signature) } - fn is_attributable() -> bool { - false + fn seed_signature( + certificate: &::Certificate, + ) -> Option { + certificate + .get() + .map(|certificate| certificate.seed_signature) } } pub type Context = CContext; pub type Seed = vrf::Seed; -pub type Notarization = CNotarization; -pub type Finalization = CFinalization; -pub type Activity = CActivity; +pub type Notarization = CNotarization; +pub type Finalization = CFinalization; +pub type Activity = CActivity; pub type PublicKey = ed25519::PublicKey; pub type Identity = ::Public; pub type Signature = ::Signature; pub trait Seedable { - /// Returns the embedded VRF seed, if present. The containing certificate must be verified - /// before the seed is used as authenticated network data. + /// Returns the certificate-derived round seed when the selected scheme carries one. fn seed(&self) -> Option; } -impl Seedable for Notarization { +impl Seedable for Notarization { fn seed(&self) -> Option { - Scheme::verified_vrf_seed_signature(&self.certificate) + S::seed_signature(&self.certificate) .map(|signature| Seed::new(self.proposal.round, signature)) } } -impl Seedable for Finalization { +impl Seedable for Finalization { fn seed(&self) -> Option { - Scheme::verified_vrf_seed_signature(&self.certificate) + S::seed_signature(&self.certificate) .map(|signature| Seed::new(self.proposal.round, signature)) } } @@ -428,322 +165,122 @@ impl Seedable for Finalization { #[cfg(test)] mod tests { use super::*; - use commonware_codec::Encode; + use commonware_codec::{DecodeExt, Encode, EncodeSize, FixedSize}; use commonware_consensus::{ - simplex::types::{verify_certificates, Notarization, Notarize, Proposal}, + simplex::types::{Notarization as CNotarization, Notarize, Proposal}, types::{Epoch, Round, View}, }; - use commonware_cryptography::{certificate::mocks::Fixture, sha256, Digest as _}; - use commonware_parallel::{Manual, Sequential}; + use commonware_cryptography::{sha256, Digest as _}; + use commonware_parallel::Sequential; use rand::{rngs::StdRng, SeedableRng}; - use std::{ - future::Future, - num::NonZeroUsize, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }, - }; - #[derive(Clone, Debug, Default)] - struct CountingStrategy { - partition_calls: Arc, - } - - impl CountingStrategy { - fn partition_calls(&self) -> usize { - self.partition_calls.load(Ordering::Relaxed) - } - } - - impl Strategy for CountingStrategy { - fn manual(&self) -> Manual { - Manual::new(self.clone(), NonZeroUsize::MIN) - } - - fn spawn(&self, f: F) -> impl Future + Send + 'static - where - F: FnOnce(Self) -> T + Send + 'static, - T: Send + 'static, - { - let strategy = self.clone(); - async move { f(strategy) } - } - - fn run(&self, len: usize, serial: SEQ, parallel: PAR) -> R - where - R: Send, - SEQ: FnOnce() -> R + Send, - PAR: FnOnce() -> R + Send, - { - Sequential.run(len, serial, parallel) - } - - fn try_run(&self, len: usize, serial: SEQ, parallel: PAR) -> Result - where - R: Send, - E: Send, - SEQ: FnOnce() -> Result + Send, - PAR: FnOnce() -> Result + Send, - { - Sequential.try_run(len, serial, parallel) - } - - fn fold_init( - &self, - iter: I, - init: INIT, - identity: ID, - fold_op: F, - reduce_op: RD, - ) -> R - where - I: IntoIterator + Send, - INIT: Fn() -> T + Send + Sync, - T: Send, - R: Send, - ID: Fn() -> R + Send + Sync, - F: Fn(R, &mut T, I::Item) -> R + Send + Sync, - RD: Fn(R, R) -> R + Send + Sync, - { - Sequential.fold_init(iter, init, identity, fold_op, reduce_op) - } - - fn try_fold( - &self, - iter: I, - identity: ID, - fold_op: F, - reduce_op: RD, - ) -> Result - where - I: IntoIterator + Send, - R: Send, - E: Send, - ID: Fn() -> R + Send + Sync, - F: Fn(R, I::Item) -> Result + Send + Sync, - RD: Fn(R, R) -> R + Send + Sync, - { - Sequential.try_fold(iter, identity, fold_op, reduce_op) - } - - fn map_partition_collect_vec(&self, iter: I, map_op: F) -> (Vec, Vec) - where - I: IntoIterator + Send, - F: Fn(I::Item) -> (K, Option) + Send + Sync, - K: Send, - U: Send, - { - self.partition_calls.fetch_add(1, Ordering::Relaxed); - Sequential.map_partition_collect_vec(iter, map_op) - } - - fn join(&self, a: A, b: B) -> (RA, RB) - where - A: FnOnce() -> RA + Send, - B: FnOnce() -> RB + Send, - RA: Send, - RB: Send, - { - Sequential.join(a, b) - } - - fn sort_by(&self, items: &mut [T], compare: C) - where - T: Send, - C: Fn(&T, &T) -> std::cmp::Ordering + Send + Sync, - { - Sequential.sort_by(items, compare); - } + fn standard_schemes(seed: u64) -> Vec { + let mut rng = StdRng::seed_from_u64(seed); + standard::fixture::(&mut rng, b"test", 4).schemes } - fn schemes(mode: CertificateMode, seed: u64) -> Vec { + fn vrf_schemes(seed: u64) -> Vec { let mut rng = StdRng::seed_from_u64(seed); - let Fixture { schemes, .. } = vrf::fixture::(&mut rng, b"test", 4); - schemes - .iter() - .map(|scheme| { - Scheme::signer( - mode, - b"test", - scheme.participants().clone(), - scheme.polynomial().clone(), - scheme.share().unwrap().clone(), - ) - .unwrap() - }) - .collect() + vrf::fixture::(&mut rng, b"test", 4).schemes } - fn notarization(schemes: &[Scheme], view: u64) -> crate::Notarization { - let proposal = Proposal::new( + fn proposal(view: u64) -> Proposal { + Proposal::new( Round::new(Epoch::new(1), View::new(view)), View::new(view - 1), sha256::Digest::EMPTY, - ); + ) + } + + fn notarization(schemes: &[S], view: u64) -> Notarization { + let proposal = proposal(view); let votes: Vec<_> = schemes .iter() .map(|scheme| Notarize::sign(scheme, proposal.clone()).unwrap()) .collect(); - Notarization::from_notarizes(&schemes[0], &votes, &Sequential).unwrap() + CNotarization::from_notarizes(&schemes[0], &votes, &Sequential).unwrap() } #[test] - fn standard_threshold_certificates_do_not_export_vrf_seeds() { - let schemes = schemes(CertificateMode::Standard, 1); + fn standard_threshold_votes_use_one_signature_on_wire() { + let schemes = standard_schemes(13); + let proposal = proposal(9); + let vote = Notarize::sign(&schemes[0], proposal.clone()).unwrap(); let notarization = notarization(&schemes, 9); - let vrf_verifier = - Scheme::certificate_verifier(CertificateMode::Vrf, b"test", *schemes[0].identity()); + let signature_size = ::SIZE; - assert!(notarization.seed().is_none()); - assert!(notarization.verify(&mut StdRng::seed_from_u64(2), &schemes[0], &Sequential)); - assert!(!notarization.verify(&mut StdRng::seed_from_u64(2), &vrf_verifier, &Sequential,)); - } - - #[test] - fn standard_threshold_certificates_reject_noncanonical_second_signature() { - let schemes = schemes(CertificateMode::Standard, 3); - let mut certificate = notarization(&schemes, 9); - let other = notarization(&schemes, 10); - let signature = certificate.certificate.get().unwrap().vote_signature; - let other_signature = other.certificate.get().unwrap().seed_signature; - certificate.certificate = ConsensusSignature { - vote_signature: signature, - seed_signature: other_signature, - } - .into(); - - assert!(!certificate.verify(&mut StdRng::seed_from_u64(4), &schemes[0], &Sequential,)); - } - - #[test] - fn standard_threshold_certificates_batch_verify() { - let schemes = schemes(CertificateMode::Standard, 5); - let first = notarization(&schemes, 9); - let mut second = notarization(&schemes, 10); - let second_vote = second.certificate.get().unwrap().vote_signature; - let first_vote = first.certificate.get().unwrap().vote_signature; - second.certificate = ConsensusSignature { - vote_signature: second_vote, - seed_signature: first_vote, - } - .into(); - let certificates = [ - ( - Subject::Notarize { - proposal: &first.proposal, - }, - &first.certificate, - ), - ( - Subject::Notarize { - proposal: &second.proposal, - }, - &second.certificate, - ), - ]; + assert_eq!(vote.attestation.signature.encode_size(), signature_size); + assert_eq!(notarization.certificate.encode_size(), signature_size); + assert_eq!( + vote.encode_size(), + proposal.encode_size() + vote.attestation.signer.encode_size() + signature_size + ); + assert_eq!( + notarization.encode_size(), + proposal.encode_size() + signature_size + ); + let encoded_vote = vote.encode(); + let encoded_notarization = notarization.encode(); + assert_eq!( + Notarize::::decode(encoded_vote.clone()).unwrap(), + vote + ); assert_eq!( - verify_certificates( - &mut StdRng::seed_from_u64(6), - &schemes[0], - &certificates, - &Sequential, - ), - vec![true, false] + Notarization::::decode(encoded_notarization.clone()).unwrap(), + notarization ); + assert!(Notarize::::decode(encoded_vote).is_err()); + assert!(Notarization::::decode(encoded_notarization).is_err()); } #[test] - fn standard_threshold_batch_uses_strategy_for_adapter_decoding() { - let schemes = schemes(CertificateMode::Standard, 11); - let proposal = Proposal::new( - Round::new(Epoch::new(1), View::new(9)), - View::new(8), - sha256::Digest::EMPTY, + fn vrf_threshold_votes_retain_two_signatures_on_wire() { + let schemes = vrf_schemes(14); + let proposal = proposal(9); + let vote = Notarize::sign(&schemes[0], proposal.clone()).unwrap(); + let notarization = notarization(&schemes, 9); + let certificate_size = 2 * ::SIZE; + + assert_eq!(vote.attestation.signature.encode_size(), certificate_size); + assert_eq!(notarization.certificate.encode_size(), certificate_size); + assert_eq!( + vote.encode_size(), + proposal.encode_size() + vote.attestation.signer.encode_size() + certificate_size ); - let attestations = schemes - .iter() - .map(|scheme| { - scheme - .sign(Subject::Notarize { - proposal: &proposal, - }) - .unwrap() - }) - .collect::>(); - let strategy = CountingStrategy::default(); - - let verification = schemes[0].verify_attestations( - &mut StdRng::seed_from_u64(12), - Subject::Notarize { - proposal: &proposal, - }, - attestations, - &strategy, + assert_eq!( + notarization.encode_size(), + proposal.encode_size() + certificate_size ); - assert_eq!(verification.verified.len(), schemes.len()); - assert!(verification.invalid.is_empty()); - assert!( - strategy.partition_calls() >= 2, - "the adapter and standard verifier must both use the parallel strategy" + let encoded_vote = vote.encode(); + let encoded_notarization = notarization.encode(); + assert_eq!( + Notarize::::decode(encoded_vote.clone()).unwrap(), + vote ); - } - - #[test] - fn vrf_mode_preserves_legacy_certificate_bytes() { - let mut rng = StdRng::seed_from_u64(7); - let Fixture { schemes, .. } = vrf::fixture::(&mut rng, b"test", 4); - let wrapped: Vec<_> = schemes - .iter() - .map(|scheme| { - Scheme::signer( - CertificateMode::Vrf, - b"test", - scheme.participants().clone(), - scheme.polynomial().clone(), - scheme.share().unwrap().clone(), - ) - .unwrap() - }) - .collect(); - let proposal = Proposal::new( - Round::new(Epoch::new(1), View::new(9)), - View::new(8), - sha256::Digest::EMPTY, + assert_eq!( + Notarization::::decode(encoded_notarization.clone()).unwrap(), + notarization ); - let legacy_votes: Vec<_> = schemes - .iter() - .map(|scheme| Notarize::sign(scheme, proposal.clone()).unwrap()) - .collect(); - let wrapped_votes: Vec<_> = wrapped - .iter() - .map(|scheme| Notarize::sign(scheme, proposal.clone()).unwrap()) - .collect(); - let legacy = Notarization::from_notarizes(&schemes[0], &legacy_votes, &Sequential).unwrap(); - let adapted = - Notarization::from_notarizes(&wrapped[0], &wrapped_votes, &Sequential).unwrap(); - - assert_eq!(legacy.certificate.encode(), adapted.certificate.encode()); + assert!(Notarize::::decode(encoded_vote).is_err()); + assert!(Notarization::::decode(encoded_notarization).is_err()); } #[test] - fn vrf_threshold_certificates_retain_round_seed() { - let schemes = schemes(CertificateMode::Vrf, 9); - let notarization = notarization(&schemes, 9); - let standard_verifier = Scheme::certificate_verifier( - CertificateMode::Standard, - b"test", - *schemes[0].identity(), - ); - - assert!(notarization.seed().is_some()); - assert!(notarization.verify(&mut StdRng::seed_from_u64(8), &schemes[0], &Sequential)); - assert!(!notarization.verify( - &mut StdRng::seed_from_u64(8), - &standard_verifier, + fn concrete_schemes_verify_and_expose_only_vrf_seeds() { + let standard = standard_schemes(15); + let standard_notarization = notarization(&standard, 9); + assert!(standard_notarization.seed().is_none()); + assert!(standard_notarization.verify( + &mut StdRng::seed_from_u64(16), + &standard[0], &Sequential, )); + + let vrf = vrf_schemes(17); + let vrf_notarization = notarization(&vrf, 9); + assert!(vrf_notarization.seed().is_some()); + assert!(vrf_notarization.verify(&mut StdRng::seed_from_u64(18), &vrf[0], &Sequential,)); } } diff --git a/types/src/lib.rs b/types/src/lib.rs index a8a14f7c..78b86292 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -11,7 +11,7 @@ pub use block::{Block, Finalized, Notarized}; mod consensus; pub use consensus::{ Activity, CertificateMode, Context, Finalization, Identity, Notarization, PublicKey, Scheme, - Seed, Seedable, Signature, + Seed, Seedable, Signature, StandardScheme, VrfScheme, }; pub mod wasm; @@ -114,7 +114,7 @@ mod tests { let n = 4; let Fixture { schemes, .. } = bls12381_threshold::fixture::(&mut rng, NAMESPACE, n); - let schemes: Vec<_> = schemes.into_iter().map(Scheme::from_vrf).collect(); + let schemes = schemes; // Create a block with context let context = Context { @@ -161,7 +161,7 @@ mod tests { let n = 4; let Fixture { schemes, .. } = bls12381_threshold::fixture::(&mut rng, NAMESPACE, n); - let schemes: Vec<_> = schemes.into_iter().map(Scheme::from_vrf).collect(); + let schemes = schemes; // Create a block with context let context = Context { diff --git a/types/src/wasm.rs b/types/src/wasm.rs index 277fcb63..1fcfcbd9 100644 --- a/types/src/wasm.rs +++ b/types/src/wasm.rs @@ -1,6 +1,6 @@ use crate::{ - Block, CertificateMode, Finalized, Identity, Notarized, Scheme, Seed, Signature, EPOCH, - NAMESPACE, + Block, Finalized, Identity, Notarized, Scheme, Seed, Signature, StandardScheme, VrfScheme, + EPOCH, NAMESPACE, }; use commonware_codec::{DecodeExt, Encode}; use commonware_consensus::{ @@ -51,13 +51,12 @@ pub struct FinalizedJs { #[wasm_bindgen] pub fn parse_seed(identity: Vec, bytes: Vec) -> JsValue { let identity = Identity::decode(identity.as_ref()).expect("invalid identity"); - let certificate_verifier = - Scheme::certificate_verifier(CertificateMode::Vrf, NAMESPACE, identity); + let certificate_verifier = VrfScheme::certificate_verifier(NAMESPACE, identity); let Ok(seed) = Seed::decode(bytes.as_ref()) else { return JsValue::NULL; }; - if !certificate_verifier.verify_seed(&seed) { + if !seed.verify(&certificate_verifier) { return JsValue::NULL; } let seed_js = SeedJs { @@ -70,20 +69,23 @@ pub fn parse_seed(identity: Vec, bytes: Vec) -> JsValue { #[wasm_bindgen] pub fn parse_notarized(identity: Vec, bytes: Vec, standard: bool) -> JsValue { let identity = Identity::decode(identity.as_ref()).expect("invalid identity"); - let mode = if standard { - CertificateMode::Standard + if standard { + parse_notarized_with::(identity, bytes) } else { - CertificateMode::Vrf - }; - let certificate_verifier = Scheme::certificate_verifier(mode, NAMESPACE, identity); + parse_notarized_with::(identity, bytes) + } +} + +fn parse_notarized_with(identity: Identity, bytes: Vec) -> JsValue { + let certificate_verifier = S::certificate_verifier(NAMESPACE, identity); - let Ok(notarized) = Notarized::decode(bytes.as_ref()) else { + let Ok(notarized) = Notarized::::decode(bytes.as_ref()) else { return JsValue::NULL; }; if !notarized.verify(&certificate_verifier, &Sequential) { return JsValue::NULL; } - let Some(certificate) = notarized.proof.certificate.get() else { + let Some(signature) = S::vote_signature(¬arized.proof.certificate) else { return JsValue::NULL; }; let notarized_js = NotarizedJs { @@ -91,7 +93,7 @@ pub fn parse_notarized(identity: Vec, bytes: Vec, standard: bool) -> JsV view: notarized.proof.view().get(), parent: notarized.proof.proposal.parent.get(), payload: notarized.proof.proposal.payload.to_vec(), - signature: certificate.vote_signature.encode().to_vec(), + signature: signature.encode().to_vec(), }, block: BlockJs { leader: notarized.block.context.leader.encode().to_vec(), @@ -107,19 +109,22 @@ pub fn parse_notarized(identity: Vec, bytes: Vec, standard: bool) -> JsV #[wasm_bindgen] pub fn parse_finalized(identity: Vec, bytes: Vec, standard: bool) -> JsValue { let identity = Identity::decode(identity.as_ref()).expect("invalid identity"); - let mode = if standard { - CertificateMode::Standard + if standard { + parse_finalized_with::(identity, bytes) } else { - CertificateMode::Vrf - }; - let certificate_verifier = Scheme::certificate_verifier(mode, NAMESPACE, identity); - let Ok(finalized) = Finalized::decode(bytes.as_ref()) else { + parse_finalized_with::(identity, bytes) + } +} + +fn parse_finalized_with(identity: Identity, bytes: Vec) -> JsValue { + let certificate_verifier = S::certificate_verifier(NAMESPACE, identity); + let Ok(finalized) = Finalized::::decode(bytes.as_ref()) else { return JsValue::NULL; }; if !finalized.verify(&certificate_verifier, &Sequential) { return JsValue::NULL; } - let Some(certificate) = finalized.proof.certificate.get() else { + let Some(signature) = S::vote_signature(&finalized.proof.certificate) else { return JsValue::NULL; }; let finalized_js = FinalizedJs { @@ -127,7 +132,7 @@ pub fn parse_finalized(identity: Vec, bytes: Vec, standard: bool) -> JsV view: finalized.proof.view().get(), parent: finalized.proof.proposal.parent.get(), payload: finalized.proof.proposal.payload.to_vec(), - signature: certificate.vote_signature.encode().to_vec(), + signature: signature.encode().to_vec(), }, block: BlockJs { leader: finalized.block.context.leader.encode().to_vec(), diff --git a/validator/src/main.rs b/validator/src/main.rs index 86fbb06c..bc1b186f 100644 --- a/validator/src/main.rs +++ b/validator/src/main.rs @@ -1,9 +1,8 @@ -use alto_chain::{engine, Config, Peers}; -use alto_client::Client; -use alto_types::{EPOCH, NAMESPACE}; +use alto_chain::{engine, Config, Leader, Peers}; +use alto_types::{Scheme, StandardScheme, VrfScheme, EPOCH, NAMESPACE}; use clap::{Arg, Command}; use commonware_codec::{Decode, DecodeExt, EncodeSize}; -use commonware_consensus::{marshal, types::ViewDelta}; +use commonware_consensus::{marshal, simplex::elector::Random, types::ViewDelta}; use commonware_cryptography::{ bls12381::primitives::{ group, @@ -282,7 +281,7 @@ fn main() { &(NZU32!(peers_u32), ModeVersion::v0()), ) .expect("polynomial is invalid"); - let identity = polynomial.public(); + let identity = *polynomial.public(); info!( ?public_key, ?identity, @@ -356,50 +355,10 @@ fn main() { let strategy = Rayon::new(NZUsize!(config.signature_threads)).unwrap(); - // Create indexer - let mut indexer = None; - if let Some(indexer_url) = config.indexer.as_deref() { - indexer = Some(Client::new_with_certificate_mode( - indexer_url, - *identity, - config.leader.certificate_mode(), - strategy.clone(), - )); - } - - // Create engine - let engine_cfg = engine::Config { - blocker: oracle.clone(), - provider: oracle.clone(), - partition_prefix: "engine".to_string(), - me: public_key.clone(), - participants, - mailbox_size: config.mailbox_size, - deque_size: config.deque_size, - block_size: config.block_size, - leader: config.leader, - leader_timeout: LEADER_TIMEOUT, - certification_timeout: CERTIFICATION_TIMEOUT, - nullify_retry: NULLIFY_RETRY, - activity_timeout: ACTIVITY_TIMEOUT, - skip_timeout: SKIP_TIMEOUT, - fetch_timeout: FETCH_TIMEOUT, - max_fetch_count: MAX_FETCH_COUNT, - max_fetch_size: MAX_FETCH_SIZE, - fetch_rate_per_peer: resolver_limit, - backfiller_max_active: config.backfiller_max_active, - backfiller_retry: Duration::from_millis(config.backfiller_retry_ms), - indexer, - polynomial, - share, - strategy, - }; - let engine = engine::Engine::new(context.child("engine"), engine_cfg).await; - let marshal_resolver_cfg = marshal::resolver::p2p::Config { public_key: public_key.clone(), peer_provider: oracle.clone(), - blocker: oracle, + blocker: oracle.clone(), mailbox_size: NZUsize!(config.mailbox_size), initial: Duration::from_secs(1), timeout: MARSHAL_RESOLVER_TIMEOUT, @@ -413,8 +372,66 @@ fn main() { marshal, ); - // Start engine - let engine = engine.start(pending, recovered, resolver, broadcaster, marshal_resolver); + macro_rules! start_consensus { + ($scheme:ty, $elector:expr, $delay_ms:expr) => {{ + let scheme = + <$scheme as Scheme>::signer(NAMESPACE, participants, polynomial, share) + .expect("failed to create consensus scheme"); + let indexer = config.indexer.as_deref().map(|indexer_url| { + alto_client::ClientBuilder::<_, $scheme>::new_with_scheme( + indexer_url, + <$scheme as Scheme>::certificate_verifier(NAMESPACE, identity), + strategy.clone(), + ) + .build() + }); + let engine_cfg = engine::Config { + blocker: oracle.clone(), + provider: oracle.clone(), + partition_prefix: "engine".to_string(), + me: public_key.clone(), + scheme, + elector: $elector, + mailbox_size: config.mailbox_size, + deque_size: config.deque_size, + block_size: config.block_size, + proposal_delay_ms: $delay_ms, + leader_timeout: LEADER_TIMEOUT, + certification_timeout: CERTIFICATION_TIMEOUT, + nullify_retry: NULLIFY_RETRY, + activity_timeout: ACTIVITY_TIMEOUT, + skip_timeout: SKIP_TIMEOUT, + fetch_timeout: FETCH_TIMEOUT, + max_fetch_count: MAX_FETCH_COUNT, + max_fetch_size: MAX_FETCH_SIZE, + fetch_rate_per_peer: resolver_limit, + backfiller_max_active: config.backfiller_max_active, + backfiller_retry: Duration::from_millis(config.backfiller_retry_ms), + indexer, + strategy, + }; + engine::Engine::new(context.child("engine"), engine_cfg) + .await + .start(pending, recovered, resolver, broadcaster, marshal_resolver) + }}; + } + + // A validator owns one concrete certificate format for its process lifetime. Every + // persistent and wire-facing component is constructed inside the selected branch. + let engine = match config.leader { + Leader::Stable { + delay_ms, + term_length, + optimistic_views, + } => start_consensus!( + StandardScheme, + engine::stable_elector(term_length, optimistic_views), + delay_ms + ), + Leader::Rotating { delay_ms } => { + start_consensus!(VrfScheme, Random, delay_ms) + } + }; // Wait for any task to error if let Err(e) = try_join_all(vec![p2p, engine]).await { From 1e0e4fbefd0fb48e678275845ac625eb23c4891a Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Wed, 12 Aug 2026 15:50:57 -0700 Subject: [PATCH 8/9] fix explorer --- explorer/src/AboutModal.tsx | 7 ++++--- explorer/src/App.tsx | 28 ++++++++++++------------- explorer/src/timelineIdentifier.test.ts | 12 +++++++++++ explorer/src/timelineIdentifier.ts | 12 +++++++++++ 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/explorer/src/AboutModal.tsx b/explorer/src/AboutModal.tsx index 7aea2378..5d9a00ae 100644 --- a/explorer/src/AboutModal.tsx +++ b/explorer/src/AboutModal.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import { MODE } from './config'; -import { getTimelineIdentifier } from './timelineIdentifier'; +import { getLeaderIndicator, getTimelineIdentifier } from './timelineIdentifier'; interface AboutModalProps { isOpen: boolean; @@ -10,6 +10,7 @@ interface AboutModalProps { const AboutModal: React.FC = ({ isOpen, onClose, standardCertificates }) => { const timelineIdentifier = getTimelineIdentifier(standardCertificates); + const leaderIndicator = getLeaderIndicator(standardCertificates); // Add effect to handle link targets useEffect(() => { if (isOpen) { @@ -80,8 +81,8 @@ const AboutModal: React.FC = ({ isOpen, onClose, standardCertif
    • -
      - {standardCertificates ? 'Proposed' : 'Seeded'} +
      + {leaderIndicator.label}
      {standardCertificates ? 'The stable round-robin leader has proposed a block.' diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index 81e77f1f..a04ab7a9 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -21,7 +21,7 @@ import { VerifiedConsensusArtifact, } from "./consensusWorkerPool"; import { scaleTimelineWidth } from "./timeline"; -import { getTimelineIdentifier, SEED_SIGNATURE_COLOR } from "./timelineIdentifier"; +import { getLeaderIndicator, getTimelineIdentifier } from "./timelineIdentifier"; import "./App.css"; import AboutModal from './AboutModal'; import './AboutModal.css'; @@ -69,18 +69,6 @@ const retainNewestViews = (views: ViewData[]): ViewData[] => { }; const center = new LatLng(0, 0); -const markerIcon = new DivIcon({ - className: "custom-div-icon", - html: `
      `, - iconSize: [12, 12], - iconAnchor: [6, 6], -}); - // ASCII Logo animation logic const initializeLogoAnimations = () => { const horizontalSymbols = [" ", "*", "+", "-", "~"]; @@ -119,7 +107,19 @@ const App: React.FC = () => { const allConfigs = useMemo(() => getClusters(), []); const { BACKEND_URL, PUBLIC_KEY_HEX, LOCATIONS, PARTICIPANTS } = clusterConfig; const standardCertificates = clusterConfig.CERTIFICATE_MODE === 'standard'; + const leaderIndicator = getLeaderIndicator(standardCertificates); const timelineIdentifier = getTimelineIdentifier(standardCertificates); + const markerIcon = useMemo(() => new DivIcon({ + className: "custom-div-icon", + html: `
      `, + iconSize: [12, 12], + iconAnchor: [6, 6], + }), [leaderIndicator.color]); const PUBLIC_KEY = useMemo(() => hexToUint8Array(PUBLIC_KEY_HEX), [PUBLIC_KEY_HEX]); const [views, setViews] = useState([]); @@ -943,7 +943,7 @@ const App: React.FC = () => {

      Timeline

      - + diff --git a/explorer/src/timelineIdentifier.test.ts b/explorer/src/timelineIdentifier.test.ts index 282315f1..1485d500 100644 --- a/explorer/src/timelineIdentifier.test.ts +++ b/explorer/src/timelineIdentifier.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "@jest/globals"; import { BLOCK_HASH_COLOR, + getLeaderIndicator, getTimelineIdentifier, SEED_SIGNATURE_COLOR, } from "./timelineIdentifier"; @@ -8,6 +9,17 @@ import { const seedSignature = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]); const blockDigest = new Uint8Array([1, 2, 3, 4, 5, 6]); +test("uses the visible block-hash color for the standard proposal indicator", () => { + expect(getLeaderIndicator(true)).toEqual({ + label: "Proposed", + color: BLOCK_HASH_COLOR, + }); + expect(getLeaderIndicator(false)).toEqual({ + label: "Seeded", + color: SEED_SIGNATURE_COLOR, + }); +}); + test("shows the block hash for standard round-robin certificates", () => { expect( getTimelineIdentifier(true, seedSignature, blockDigest), diff --git a/explorer/src/timelineIdentifier.ts b/explorer/src/timelineIdentifier.ts index b437d93d..0b369f5e 100644 --- a/explorer/src/timelineIdentifier.ts +++ b/explorer/src/timelineIdentifier.ts @@ -10,6 +10,18 @@ export interface TimelineIdentifier { fullValue: string; } +export interface LeaderIndicator { + label: "Proposed" | "Seeded"; + color: string; +} + +export const getLeaderIndicator = ( + standardCertificates: boolean, +): LeaderIndicator => ({ + label: standardCertificates ? "Proposed" : "Seeded", + color: standardCertificates ? BLOCK_HASH_COLOR : SEED_SIGNATURE_COLOR, +}); + export const getTimelineIdentifier = ( standardCertificates: boolean, seedSignature?: Uint8Array, From 6b385e3a284349a67c85d35a58c063204cc5bd67 Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Wed, 12 Aug 2026 17:10:33 -0700 Subject: [PATCH 9/9] progress --- chain/src/lib.rs | 24 ++++++++++++++++ deploy.sh | 2 ++ deploy/README.md | 8 ++++-- deploy/src/main.rs | 67 ++++++++++++++++++++++++++++++++++++++++--- validator/src/main.rs | 19 ++++++------ 5 files changed, 103 insertions(+), 17 deletions(-) diff --git a/chain/src/lib.rs b/chain/src/lib.rs index 69409e43..37379c3e 100644 --- a/chain/src/lib.rs +++ b/chain/src/lib.rs @@ -17,6 +17,7 @@ pub const DEFAULT_BACKFILLER_RETRY_MS: u64 = 1_000; pub const DEFAULT_BLOCKING_THREADS: usize = 512; pub const DEFAULT_STORAGE_BUFFER_POOL_MAX_PER_CLASS: NonZeroU32 = NZU32!(16_384); pub const DEFAULT_NETWORK_BUFFER_POOL_MAX_PER_CLASS: NonZeroU32 = NZU32!(4_096); +const DEFAULT_TRACES_SAMPLE_RATE: f64 = 0.0; const DEFAULT_STABLE_LEADER_DELAY_MS: NonZeroU64 = NZU64!(10); const DEFAULT_STABLE_LEADER_TERM_LENGTH: NonZeroU32 = NZU32!(1_000); const DEFAULT_STABLE_LEADER_OPTIMISTIC_VIEWS: u64 = 48; @@ -41,6 +42,23 @@ fn default_network_buffer_pool_max_per_class() -> Option { Some(DEFAULT_NETWORK_BUFFER_POOL_MAX_PER_CLASS) } +const fn default_traces_sample_rate() -> f64 { + DEFAULT_TRACES_SAMPLE_RATE +} + +fn deserialize_traces_sample_rate<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let rate = f64::deserialize(deserializer)?; + if rate.is_finite() && (0.0..=1.0).contains(&rate) { + return Ok(rate); + } + Err(serde::de::Error::custom( + "traces sample rate must be between 0 and 1", + )) +} + const fn default_stable_leader_optimistic_views() -> u64 { DEFAULT_STABLE_LEADER_OPTIMISTIC_VIEWS } @@ -156,6 +174,12 @@ pub struct Config { #[serde(default)] pub network_buffer_pool_parallelism: Option, pub log_level: String, + #[serde( + default = "default_traces_sample_rate", + deserialize_with = "deserialize_traces_sample_rate" + )] + /// Fraction of traces exported to the configured collector; zero disables tracing. + pub traces_sample_rate: f64, pub local: bool, pub allowed_peers: Vec, diff --git a/deploy.sh b/deploy.sh index 82450a4e..e8a971fd 100755 --- a/deploy.sh +++ b/deploy.sh @@ -59,6 +59,7 @@ cargo run --locked --bin deploy -- generate \ --worker-threads 8 \ --network-buffer-pool-max-per-class 16384 \ --log-level info \ + --traces-sample-rate 0 \ --mailbox-size 16384 \ --deque-size 256 \ --block-size 0 \ @@ -124,6 +125,7 @@ while IFS= read -r validator_config; do for expected_setting in \ 'worker_threads: 8' \ 'network_buffer_pool_max_per_class: 16384' \ + 'traces_sample_rate: 0.0' \ 'signature_threads: 16'; do if ! grep -qx "$expected_setting" "assets/${validator_config}"; then echo "validator config is missing '${expected_setting}': assets/${validator_config}" >&2 diff --git a/deploy/README.md b/deploy/README.md index 546904a5..17197428 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -31,6 +31,9 @@ together. `deploy.sh` generates a new network identity and infrastructure for th and defaults to `0`. Validators reject only values whose encoded blocks exceed the authenticated transport capacity. All validators in a network must use the same value. +`--traces-sample-rate` sets the fraction of traces exported by each validator and accepts values +from `0` through `1`. It defaults to `0`, which disables trace export. + Generated validator configs use: ```yaml @@ -40,11 +43,12 @@ leader: term_length: 1000 optimistic_views: 48 block_size: 0 +traces_sample_rate: 0.0 indexer: http://localhost:8080 ``` ```bash -cargo run --bin deploy -- generate --peers 5 --bootstrappers 1 --worker-threads 3 --log-level info --mailbox-size 16384 --deque-size 256 --signature-threads 2 --leader-mode stable --leader-delay-ms 10 --leader-term-length 1000 --leader-optimistic-views 48 --output test local --start-port 3000 --indexers 'http://localhost:8080:1' +cargo run --bin deploy -- generate --peers 5 --bootstrappers 1 --worker-threads 3 --log-level info --traces-sample-rate 0 --mailbox-size 16384 --deque-size 256 --signature-threads 2 --leader-mode stable --leader-delay-ms 10 --leader-term-length 1000 --leader-optimistic-views 48 --output test local --start-port 3000 --indexers 'http://localhost:8080:1' ``` _If the command succeeds, you should see the following output:_ @@ -152,7 +156,7 @@ instance discards its NVMe data._ ##### USA ```bash -cargo run --bin deploy -- generate --peers 50 --bootstrappers 5 --worker-threads 2 --log-level info --mailbox-size 16384 --deque-size 256 --signature-threads 2 --leader-mode stable --leader-delay-ms 10 --leader-term-length 1000 --leader-optimistic-views 48 --output assets remote --regions us-east-1,us-east-2,us-west-1,us-west-2 --monitoring-instance-type c8g.4xlarge --monitoring-storage-size 100 --instance-type c8g.large --storage-size 75 --dashboard deploy/dashboard.json +cargo run --bin deploy -- generate --peers 50 --bootstrappers 5 --worker-threads 2 --log-level info --traces-sample-rate 0 --mailbox-size 16384 --deque-size 256 --signature-threads 2 --leader-mode stable --leader-delay-ms 10 --leader-term-length 1000 --leader-optimistic-views 48 --output assets remote --regions us-east-1,us-east-2,us-west-1,us-west-2 --monitoring-instance-type c8g.4xlarge --monitoring-storage-size 100 --instance-type c8g.large --storage-size 75 --dashboard deploy/dashboard.json ``` _This configuration consumes ~30MB of disk space per hour per validator (~13 views per second). With 75GB of storage allocated, validators will exhaust available storage in ~3 months._ diff --git a/deploy/src/main.rs b/deploy/src/main.rs index 0194a382..d0f005da 100644 --- a/deploy/src/main.rs +++ b/deploy/src/main.rs @@ -77,6 +77,23 @@ fn block_size_arg() -> Arg { .value_parser(value_parser!(u32)) } +fn parse_traces_sample_rate(value: &str) -> Result { + let rate = value + .parse::() + .map_err(|_| "traces sample rate must be a number between 0 and 1".to_string())?; + if rate.is_finite() && (0.0..=1.0).contains(&rate) { + return Ok(rate); + } + Err("traces sample rate must be between 0 and 1".to_string()) +} + +fn traces_sample_rate_arg() -> Arg { + Arg::new("traces_sample_rate") + .long("traces-sample-rate") + .default_value("0") + .value_parser(parse_traces_sample_rate) +} + #[derive(Clone, Debug, PartialEq, Eq)] struct ConfiguredIndexer { url: String, @@ -270,6 +287,7 @@ fn main() { .required(true) .value_parser(value_parser!(String)), ) + .arg(traces_sample_rate_arg()) .arg( Arg::new("mailbox_size") .long("mailbox-size") @@ -414,6 +432,7 @@ fn main() { .get_one::("network_buffer_pool_parallelism") .copied(); let log_level = sub_matches.get_one::("log_level").unwrap().clone(); + let traces_sample_rate = *sub_matches.get_one::("traces_sample_rate").unwrap(); let mailbox_size = *sub_matches.get_one::("mailbox_size").unwrap(); let deque_size = *sub_matches.get_one::("deque_size").unwrap(); let block_size = *sub_matches.get_one::("block_size").unwrap(); @@ -435,6 +454,7 @@ fn main() { storage_buffer_pool_parallelism, network_buffer_pool_parallelism, log_level, + traces_sample_rate, mailbox_size, deque_size, block_size, @@ -453,6 +473,7 @@ fn main() { storage_buffer_pool_parallelism, network_buffer_pool_parallelism, log_level, + traces_sample_rate, mailbox_size, deque_size, block_size, @@ -500,6 +521,7 @@ fn generate_local( storage_buffer_pool_parallelism: Option, network_buffer_pool_parallelism: Option, log_level: String, + traces_sample_rate: f64, mailbox_size: usize, deque_size: usize, block_size: u32, @@ -579,6 +601,7 @@ fn generate_local( storage_buffer_pool_parallelism, network_buffer_pool_parallelism, log_level: log_level.clone(), + traces_sample_rate, local: true, allowed_peers: allowed_peers.clone(), @@ -689,6 +712,7 @@ fn generate_remote( storage_buffer_pool_parallelism: Option, network_buffer_pool_parallelism: Option, log_level: String, + traces_sample_rate: f64, mailbox_size: usize, deque_size: usize, block_size: u32, @@ -794,6 +818,7 @@ fn generate_remote( storage_buffer_pool_parallelism, network_buffer_pool_parallelism, log_level: log_level.clone(), + traces_sample_rate, local: false, allowed_peers: allowed_peers.clone(), @@ -1088,7 +1113,7 @@ fn explorer_remote(dir: String, backend_url: String) { mod tests { use super::{ block_size_arg, certificate_mode_name, leader_args, parse_indexers, parse_leader, - select_regional_peers, ConfiguredIndexer, + select_regional_peers, traces_sample_rate_arg, ConfiguredIndexer, }; use alto_chain::Leader; use alto_types::CertificateMode; @@ -1114,7 +1139,32 @@ mod tests { } #[test] - fn validator_config_defaults_block_size_to_zero() { + fn traces_sample_rate_accepts_only_fractions() { + let matches = Command::new("test") + .arg(traces_sample_rate_arg()) + .try_get_matches_from(["test"]) + .unwrap(); + assert_eq!(*matches.get_one::("traces_sample_rate").unwrap(), 0.0); + + let matches = Command::new("test") + .arg(traces_sample_rate_arg()) + .try_get_matches_from(["test", "--traces-sample-rate", "0.0001"]) + .unwrap(); + assert_eq!( + *matches.get_one::("traces_sample_rate").unwrap(), + 0.0001 + ); + + for invalid in ["-0.1", "1.1", "NaN"] { + assert!(Command::new("test") + .arg(traces_sample_rate_arg()) + .try_get_matches_from(["test", "--traces-sample-rate", invalid]) + .is_err()); + } + } + + #[test] + fn validator_config_defaults_optional_settings() { let yaml = r#" private_key: key share: share @@ -1134,10 +1184,19 @@ signature_threads: 1 let config: alto_chain::Config = serde_yaml::from_str(yaml).unwrap(); assert_eq!(config.block_size, 0); + assert_eq!(config.traces_sample_rate, 0.0); - let config: alto_chain::Config = - serde_yaml::from_str(&format!("{yaml}\nblock_size: 4096\n")).unwrap(); + let config: alto_chain::Config = serde_yaml::from_str(&format!( + "{yaml}\nblock_size: 4096\ntraces_sample_rate: 0.0001\n" + )) + .unwrap(); assert_eq!(config.block_size, 4096); + assert_eq!(config.traces_sample_rate, 0.0001); + + assert!(serde_yaml::from_str::(&format!( + "{yaml}\ntraces_sample_rate: 1.1\n" + )) + .is_err()); } #[test] diff --git a/validator/src/main.rs b/validator/src/main.rs index bc1b186f..5d32e9c7 100644 --- a/validator/src/main.rs +++ b/validator/src/main.rs @@ -45,12 +45,6 @@ const MARSHAL_CHANNEL: u64 = 4; const BASE_CHANNEL_QUOTA_PER_SECOND: u32 = 1_500; const VOTING_CHANNEL_QUOTA_PER_SECOND: u32 = 3_000; -// Fraction of traces sampled and exported to the monitoring host's OTLP collector. -// Export everything: partial sampling hides the rare events we care about (a leader -// missing its deadline shows up a handful of times per hour, so a 10% sample is -// likely to miss it entirely). -const TRACES_SAMPLE_RATE: f64 = 1.0; - const LEADER_TIMEOUT: Duration = Duration::from_secs(1); const CERTIFICATION_TIMEOUT: Duration = Duration::from_secs(2); const NULLIFY_RETRY: Duration = Duration::from_secs(10); @@ -189,11 +183,14 @@ fn main() { .collect(); config.indexer = Some(resolve_named_http_url(indexer_url, &hosts_by_name)); } - let traces = hosts.as_ref().map(|hosts| tokio::tracing::Config { - endpoint: format!("http://{}:4318/v1/traces", hosts.monitoring.private), - name: public_key.to_string(), - rate: TRACES_SAMPLE_RATE, - }); + let traces = hosts + .as_ref() + .filter(|_| config.traces_sample_rate > 0.0) + .map(|hosts| tokio::tracing::Config { + endpoint: format!("http://{}:4318/v1/traces", hosts.monitoring.private), + name: public_key.to_string(), + rate: config.traces_sample_rate, + }); tokio::telemetry::init( context.child("telemetry"), tokio::telemetry::Logs {