diff --git a/Cargo.lock b/Cargo.lock index 33c4e143..fc80648f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,6 +642,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -741,7 +747,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -929,6 +935,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "color-eyre" version = "0.6.5" @@ -979,6 +991,12 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1020,6 +1038,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1089,6 +1116,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csv" version = "1.4.0" @@ -1110,6 +1146,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek-ng" version = "4.1.1" @@ -1158,6 +1203,16 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.6" @@ -1184,10 +1239,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "crypto-common 0.2.2", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -1691,6 +1755,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "ctutils", + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "0.14.32" @@ -2064,6 +2139,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2296,6 +2381,34 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8", + "shake", + "signature", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", + "zeroize", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2537,6 +2650,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2814,7 +2937,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffef0520d30fbd4151fb20e262947ae47fb0ab276a744a19b6398438105a072" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "fixedbitset", "once_cell", "readme-rustdocifier", @@ -3297,7 +3420,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -3309,10 +3432,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3359,6 +3493,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3400,6 +3543,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "ssh2" version = "0.9.5" @@ -3452,6 +3611,7 @@ dependencies = [ "libc", "lz4_flex", "memmap2", + "ml-dsa", "parking_lot", "prettytable-rs", "prometheus 0.13.4", diff --git a/Cargo.toml b/Cargo.toml index 9efcc775..1f7c4821 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ color-eyre = "0.6.2" eyre = "0.6.8" futures = "0.3.28" reqwest = { version = "0.12", features = ["json"] } -serde = { version = "1.0.163", features = ["derive"] } +serde = { version = "1.0.163", features = ["derive", "rc"] } tempfile = "3.6.0" tokio = { version = "1.28.1", features = ["full"] } tracing = "0.1.37" diff --git a/README.md b/README.md index 8afdc05e..7d622ff8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This repository is a benchmarking framework for DAG-based BFT consensus protocols in the partially synchronous model, implemented in Rust. -It includes 9 protocol implementations with configurable +It includes 11 protocol implementations with configurable dissemination strategies, storage backends, and Byzantine fault injection. @@ -22,6 +22,8 @@ injection. | Starfish-Speed | `starfish-speed` | 4.5δ | Uncertified | Encoded | Push | O(n⁴) | O(n⁴) | -- | | Sparse-Starfish-Speed | `sparse-starfish-speed` | 4.5δ | Uncertified | Encoded | Push | O(n²) | O(n³) | -- | | Starfish | `starfish` | 5.5δ | Uncertified | Encoded | Push | O(n⁴) | O(n⁴) | [eprint.iacr.org/2025/567](https://eprint.iacr.org/2025/567) | +| Starfish-RBC (prototype) | `starfish-rbc` | TBD | RBC-certified headers | Encoded | Push | TBD | TBD | [design](docs/starfish-rbc-protocol.md) | +| Single-DAG Starfish-RBC (testbed) | `starfish-rbc-single-dag` | TBD | RBC-certified blocks | Encoded | Push | TBD | TBD | [design](docs/starfish-rbc-single-dag-v3.md) | | Cordial Miners | `cordial-miners` | 6δ | Uncertified | Full | Push | O(n³) | O(n⁴) | [arxiv.org/pdf/2205.09174](https://arxiv.org/pdf/2205.09174) | | Sailfish++ | `sailfish-pp` | 6δ | Certified | Full | Pull | O(n³) | O(n⁴) | [arxiv.org/abs/2505.02761](https://arxiv.org/abs/2505.02761) | | Starfish-BLS | `starfish-bls` | 6.5δ | Uncertified | Encoded | Push | O(n²) | O(n³) | [eprint.iacr.org/2025/567](https://eprint.iacr.org/2025/567)* | @@ -43,6 +45,14 @@ and compressed block references. certificate tracking, similar in architecture to Mysticeti-BLS, but with cheaper certification. **Starfish** uses push dissemination for headers and Reed-Solomon encoded shards with acknowledgment references between validators. +**Starfish-RBC** composes plain Starfish with direct Bracha reliable broadcast of canonical +headers. ECHO and READY are recipient-authenticated with pairwise MACs; the author's INIT can use +Ed25519, ML-DSA-44, ML-DSA-65, or one recipient-specific MAC. It is a correctness-oriented research +prototype with the limitations documented in its [protocol specification](docs/starfish-rbc-protocol.md). +**Single-DAG Starfish-RBC** embeds ECHO/READY references in ordinary Starfish +blocks, avoiding a second carrier DAG. Its optional receiver-local quorum-ECHO +flag is a signature-free latency lower bound and does not provide Byzantine +totality; see the [V3 design](docs/starfish-rbc-single-dag-v3.md). **Starfish-Speed** adds strong-vote optimistic sequencing for lower latency when validators share the leader's acknowledgments. **Sparse-Starfish-Speed** (work in progress) combines Bluestreak's @@ -59,6 +69,62 @@ achieving 2-round optimistic commit latency. leader, data availability) in block headers, with async verification offloaded from the critical path. +### Block authentication + +Every consensus protocol can select its public block-signature scheme independently. +Starfish-RBC additionally supports a receiver-specific MAC for the author's initial header: + +| Scheme | CLI option | +|---|---| +| Ed25519 (default) | `--block-authentication ed25519` or omit the option | +| ML-DSA-44 | `--block-authentication ml-dsa-44` | +| ML-DSA-65 | `--block-authentication ml-dsa-65` | +| Pairwise MAC (Starfish-RBC only) | `--block-authentication mac` | + +For example, `--consensus mysticeti --block-authentication ml-dsa-65` changes +Mysticeti's block signature without creating another consensus protocol. This +selection is also available through the orchestrator. Protocol-specific BLS +certificates are unaffected. These digital-signature selections retain the +transferable public verification assumed by the protocols and do not change +their message flow or proof structure. The same value can be set as +`block_authentication` in the node-parameters YAML; the CLI option overrides +that setting. + +`BlockReference.digest` is the BLAKE3 hash of the canonical block content only. +The modular authentication proof is a separate header field and does not change +the block reference. Benchmark genesis generates all Ed25519 and ML-DSA key +material regardless of the selected signature scheme. + +The ML-DSA wrappers are generated from a common parameter-set definition. +ML-DSA-44 uses 1,312-byte public keys and 2,420-byte signatures; ML-DSA-65 +uses 1,952-byte public keys and 3,309-byte signatures. + +This is research/benchmark code. The RustCrypto `ml-dsa` implementation used +here states that it has not been independently audited and should not be +treated as production-ready cryptography. + +#### Experimental MAC protocols + +`--consensus starfish-rbc --block-authentication mac` runs the reliable-header-broadcast +prototype. It sends one author tag to each intended recipient, then runs the same pairwise-MAC +ECHO/READY flow used by Starfish-RBC's signature-authenticated modes. Only locally delivered, +dependency-closed headers enter the clean consensus DAG. + +`starfish-mac`, `starfish-speed-mac`, `sparse-starfish-speed-mac`, and +`bluestreak-mac` remain separate work-in-progress benchmark protocols. They are +not interchangeable signature selections and cannot be combined with +`--block-authentication`. + +These variants measure a lower bound for pairwise-MAC authentication. Direct +author streaming carries the full committee-sized MAC vector; relays and +synchronization responses carry only the destination's tag. Pairwise MACs do +not provide transferable authorship, and a Byzantine author can give different +recipients valid and invalid tags for the same block reference. These lower-bound modes do not add +the quorum-authentication/RBC exchange needed to bind +the author to an available authenticator. It therefore makes no safety or +liveness claim and must not be treated as a proven variant of the underlying +protocol. + ## Dissemination Modes Every protocol can run with any of three dissemination strategies diff --git a/crates/orchestrator/README.md b/crates/orchestrator/README.md index f441425c..33eb82bc 100644 --- a/crates/orchestrator/README.md +++ b/crates/orchestrator/README.md @@ -129,7 +129,16 @@ each load generator submits a fixed load of 100 tx/s or more precisely 10 tx every 100ms. Performance measurements are collected by regularly scraping the Prometheus metrics exposed by the load generators. -Available consensus protocols: `starfish`, `starfish-speed`, `sparse-starfish-speed`, `starfish-bls`, `mysticeti`, `mysticeti-bls`, `bluestreak`, `cordial-miners`, `sailfish-pp`. +Available consensus protocols: `starfish`, `starfish-speed`, +`sparse-starfish-speed`, `bluestreak`, `starfish-bls`, `mysticeti`, +`mysticeti-bls`, `cordial-miners`, and `sailfish-pp`. Select the block signature +for any protocol with `--block-authentication ed25519|ml-dsa-44|ml-dsa-65`; +Ed25519 is the default. The `starfish-mac`, `starfish-speed-mac`, +`sparse-starfish-speed-mac`, and `bluestreak-mac` names are separate +experimental protocols and cannot be combined with that option. +The research protocols `starfish-rbc` and `starfish-rbc-single-dag` are also +available; pairwise-MAC initial authentication is selected with +`--block-authentication mac`. To run with Byzantine validators: diff --git a/crates/orchestrator/src/main.rs b/crates/orchestrator/src/main.rs index eec38cac..1e39dcc9 100644 --- a/crates/orchestrator/src/main.rs +++ b/crates/orchestrator/src/main.rs @@ -58,6 +58,11 @@ pub struct Opts { )] settings_path: String, + /// Block signature scheme used by every selected consensus protocol. + /// Defaults to Ed25519. Not applicable to experimental `*-mac` protocols. + #[clap(long, value_name = "ed25519|ml-dsa-44|ml-dsa-65|mac", global = true)] + block_authentication: Option, + /// The type of operation to run. #[clap(subcommand)] operation: Operation, @@ -134,10 +139,9 @@ pub enum Operation { #[clap(long, action, default_value_t = false, global = true)] skip_testbed_configuration: bool, - /// Protocols to benchmark in order. Available options: - /// starfish | starfish-speed | sparse-starfish-speed | - /// starfish-bls | mysticeti | mysticeti-bls | - /// cordial-miners | bluestreak | sailfish-pp + /// Consensus protocols to benchmark in order. The `*-mac` names are + /// separate experimental protocols; signature schemes are selected + /// with `--block-authentication`. #[clap( long, value_name = "STRING", @@ -847,6 +851,7 @@ fn load_benchmark_configs( dissemination_mode: &Option, compress_network: Option, bls_workers: Option, + block_authentication: &Option, ) -> eyre::Result<(NodeParameters, ClientParameters)> { let mut node_parameters = match &settings.node_parameters_path { Some(path) => NodeParameters::load(path).wrap_err("Failed to load node's parameters")?, @@ -854,6 +859,9 @@ fn load_benchmark_configs( }; node_parameters.adversarial_latency = adversarial_latency; node_parameters.adversarial_latency_percent = adversarial_latency_percent; + if block_authentication.is_some() { + node_parameters.block_authentication = block_authentication.clone(); + } if let Some(workers) = bls_workers { node_parameters.bls_verification_workers = workers; } @@ -1033,6 +1041,7 @@ async fn run( .await .wrap_err("Failed to crate testbed")?; + let block_authentication = opts.block_authentication.clone(); match opts.operation { Operation::Testbed { action } => match action { // Display the current status of the testbed. @@ -1229,6 +1238,7 @@ async fn run( &dissemination_mode, compress_network, resolved_bls_workers.override_workers, + &block_authentication, )?; display::newline(); @@ -1395,6 +1405,7 @@ async fn run( &dissemination_mode, compress_network, resolved_bls_workers.override_workers, + &block_authentication, )?; display::newline(); @@ -1601,6 +1612,7 @@ async fn run( &dissemination_mode, compress_network, resolved_bls_workers.override_workers, + &block_authentication, )?; display::newline(); @@ -1766,6 +1778,7 @@ async fn run( &dissemination_mode, compress_network, resolved_bls_workers.override_workers, + &block_authentication, )?; display::newline(); @@ -1971,6 +1984,7 @@ async fn run( &dissemination_mode, compress_network, resolved_bls_workers.override_workers, + &block_authentication, )?; display::newline(); @@ -2279,6 +2293,8 @@ mod tests { let opts = Opts::try_parse_from([ "orchestrator", "benchmark", + "--block-authentication", + "ml-dsa-65", "--protocols", "starfish", "mysticeti", @@ -2290,6 +2306,8 @@ mod tests { ]) .unwrap(); + assert_eq!(opts.block_authentication.as_deref(), Some("ml-dsa-65")); + match opts.operation { Operation::Benchmark { protocols, @@ -2305,6 +2323,25 @@ mod tests { } } + #[test] + fn benchmark_parses_starfish_rbc_mac_authentication() { + let opts = Opts::try_parse_from([ + "orchestrator", + "benchmark", + "--block-authentication", + "mac", + "--protocols", + "starfish-rbc", + ]) + .unwrap(); + + assert_eq!(opts.block_authentication.as_deref(), Some("mac")); + let Operation::Benchmark { protocols, .. } = opts.operation else { + panic!("expected benchmark operation"); + }; + assert_eq!(protocols, vec!["starfish-rbc"]); + } + #[test] fn committee_sweep_parses_grouped_protocols_and_sizes() { let opts = Opts::try_parse_from([ diff --git a/crates/orchestrator/src/protocol/starfish.rs b/crates/orchestrator/src/protocol/starfish.rs index 7d0b0e86..28630799 100644 --- a/crates/orchestrator/src/protocol/starfish.rs +++ b/crates/orchestrator/src/protocol/starfish.rs @@ -116,7 +116,10 @@ impl ProtocolCommands for StarfishProtocol { .collect::>() .join(" "); - let node_parameters = parameters.node_parameters.clone(); + let node_parameters = Self::node_parameters_for_genesis( + ¶meters.consensus_protocol, + parameters.node_parameters.clone(), + ); let node_parameters_string = serde_yaml::to_string(&node_parameters).unwrap(); let node_parameters_path = self.working_dir.join("node-parameters.yaml"); let upload_node_parameters = @@ -256,6 +259,19 @@ impl StarfishProtocol { .join(format!("parameters-{authority}.yaml")) } + fn node_parameters_for_genesis( + consensus_protocol: &str, + mut node_parameters: StarfishNodeParameters, + ) -> StarfishNodeParameters { + if matches!( + consensus_protocol, + "starfish-rbc" | "starfish-rbc-single-dag" + ) { + node_parameters.refresh_starfish_rbc_protocol_instance(); + } + node_parameters + } + fn write_remote_file_command(path: &Path, contents: &str) -> String { let mut delimiter = "STARFISH_REMOTE_FILE_EOF".to_string(); while contents.contains(&delimiter) { @@ -286,7 +302,31 @@ impl StarfishProtocol { #[cfg(test)] mod tests { - use super::StarfishProtocol; + use super::{StarfishNodeParameters, StarfishProtocol}; + + #[test] + fn starfish_rbc_genesis_gets_one_nonzero_protocol_instance() { + for protocol in ["starfish-rbc", "starfish-rbc-single-dag"] { + let parameters = StarfishProtocol::node_parameters_for_genesis( + protocol, + StarfishNodeParameters::default(), + ); + assert!( + parameters + .starfish_rbc_protocol_instance + .is_some_and(|instance| instance != [0; 32]) + ); + } + } + + #[test] + fn non_rbc_genesis_does_not_need_a_protocol_instance() { + let parameters = StarfishProtocol::node_parameters_for_genesis( + "starfish", + StarfishNodeParameters::default(), + ); + assert_eq!(parameters.starfish_rbc_protocol_instance, None); + } #[test] fn split_authority_load_preserves_total_load() { diff --git a/crates/starfish-core/Cargo.toml b/crates/starfish-core/Cargo.toml index fc9fd95e..0a6332fc 100644 --- a/crates/starfish-core/Cargo.toml +++ b/crates/starfish-core/Cargo.toml @@ -22,6 +22,7 @@ hex = "0.4.3" libc = "0.2.146" lz4_flex = "0.11" memmap2 = "0.7.0" +ml-dsa = { version = "0.1.1", default-features = false, features = ["alloc", "zeroize"] } parking_lot = "0.12.1" prettytable-rs = "0.10" diff --git a/crates/starfish-core/src/block_manager.rs b/crates/starfish-core/src/block_manager.rs index f05e8d5f..484b45eb 100644 --- a/crates/starfish-core/src/block_manager.rs +++ b/crates/starfish-core/src/block_manager.rs @@ -56,15 +56,47 @@ impl BlockManager { let mut updated_existing_with_transactions: Vec> = vec![]; // Blocks to insert into the DAG in a single batched write lock. let mut blocks_to_insert: Vec> = vec![]; + // References first discovered in this batch are not visible through + // DagState until the final batched insert. Keep their positions so a + // richer duplicate later in the same batch can upgrade that pending + // insertion instead of being mistaken for an already stored block. + let mut new_blocks_in_batch: AHashMap = AHashMap::new(); + let mut updated_blocks_in_batch: AHashMap = AHashMap::new(); // missing references that we don't currently have let mut missing_references = AHashSet::new(); let mut block_exists_cache: AHashMap = AHashMap::new(); + let include_ack_dependencies = self.dag_state.consensus_protocol.is_starfish_rbc(); while let Some(block) = blocks.pop_front() { let block_reference = block.reference(); if let Some(existing_pending_block) = self.blocks_pending.get_mut(block_reference) { - if block.transactions().is_some() { - *existing_pending_block = block; + if let Some(mut merged) = existing_pending_block.merge_same_block(&block) { + merged.preserialize(); + *existing_pending_block = Data::new(merged); + } + continue; + } + + if let Some((insert_index, updated_index)) = + updated_blocks_in_batch.get(block_reference).copied() + { + if let Some(mut merged) = blocks_to_insert[insert_index].merge_same_block(&block) { + merged.preserialize(); + let merged = Data::new(merged); + blocks_to_insert[insert_index] = merged.clone(); + updated_existing_with_transactions[updated_index] = merged; + } + continue; + } + + if let Some((insert_index, processed_index)) = + new_blocks_in_batch.get(block_reference).copied() + { + if let Some(mut merged) = blocks_to_insert[insert_index].merge_same_block(&block) { + merged.preserialize(); + let merged = Data::new(merged); + blocks_to_insert[insert_index] = merged.clone(); + newly_processed[processed_index] = merged; } continue; } @@ -76,18 +108,34 @@ impl BlockManager { // Block already in store — check if this version brings new transaction data if self.dag_state.contains_new_transactions(&block) { tracing::debug!("Block has new transactions: {:?}", block_reference); + let stored_reference = *block_reference; + let mut merged = self + .dag_state + .get_storage_block(stored_reference) + .and_then(|existing| existing.merge_same_block(&block)); + let block = if let Some(ref mut merged) = merged { + merged.preserialize(); + Data::new(merged.clone()) + } else { + block + }; + let insert_index = blocks_to_insert.len(); blocks_to_insert.push(block.clone()); + let updated_index = updated_existing_with_transactions.len(); updated_existing_with_transactions.push(block); + updated_blocks_in_batch.insert(stored_reference, (insert_index, updated_index)); + } else { + self.dag_state.upgrade_mac_authentication(&block); } continue; } let mut processed = true; - for included_reference in block.block_references() { - if self.blocks_pending.contains_key(included_reference) { + for included_reference in Self::dependencies(&block, include_ack_dependencies) { + if self.blocks_pending.contains_key(&included_reference) { processed = false; self.block_references_waiting - .entry(*included_reference) + .entry(included_reference) .or_default() .insert(*block_reference); continue; @@ -96,21 +144,21 @@ impl BlockManager { // If we are missing a reference then we insert // into pending and update the waiting index if !*block_exists_cache - .entry(*included_reference) - .or_insert_with(|| self.dag_state.block_exists(*included_reference)) + .entry(included_reference) + .or_insert_with(|| self.dag_state.block_exists(included_reference)) { processed = false; self.block_references_waiting - .entry(*included_reference) + .entry(included_reference) .or_default() .insert(*block_reference); - if !self.blocks_pending.contains_key(included_reference) { + if !self.blocks_pending.contains_key(&included_reference) { // add missing references if it is not available // in both pending set and storage - missing_references.insert(*included_reference); + missing_references.insert(included_reference); self.missing[included_reference.authority as usize] - .insert(*included_reference); + .insert(included_reference); } } } @@ -122,9 +170,12 @@ impl BlockManager { let block_reference = *block_reference; // Defer DAG insertion — will be done in batch after the loop. + let insert_index = blocks_to_insert.len(); blocks_to_insert.push(block.clone()); block_exists_cache.insert(block_reference, true); + let processed_index = newly_processed.len(); newly_processed.push(block); + new_blocks_in_batch.insert(block_reference, (insert_index, processed_index)); // Now unlock any pending blocks, and process them if ready. if let Some(waiting_references) = @@ -139,8 +190,7 @@ impl BlockManager { primary key.", ); - if block_pointer - .block_references() + if Self::dependencies(block_pointer, include_ack_dependencies) .iter() .all(|item_ref| !self.block_references_waiting.contains_key(item_ref)) { @@ -170,6 +220,28 @@ impl BlockManager { ) } + /// Dirty-DAG connection normally follows causal parents. Starfish-RBC + /// also waits for logical acknowledgment targets because clean activation + /// treats them as sequencing dependencies and the normal missing-parent + /// request path is the contained way to fetch their headers. + fn dependencies(block: &VerifiedBlock, include_acknowledgments: bool) -> Vec { + let mut seen = AHashSet::new(); + let mut dependencies = Vec::new(); + for reference in block.block_references() { + if seen.insert(*reference) { + dependencies.push(*reference); + } + } + if include_acknowledgments { + for reference in block.acknowledgments() { + if seen.insert(reference) { + dependencies.push(reference); + } + } + } + dependencies + } + pub fn missing_blocks(&self) -> &[AHashSet] { &self.missing } @@ -194,3 +266,236 @@ impl BlockManager { /// evicting unresolved chains from the block manager. pub fn cleanup(&mut self, _threshold_round: RoundNumber) {} } + +#[cfg(test)] +mod tests { + use prometheus::Registry; + use tempfile::TempDir; + + use super::*; + use crate::{ + config::{DisseminationMode, StorageBackend}, + crypto, + dag_state::ConsensusProtocol, + metrics::Metrics, + types::{AuthorityIndex, BlockAuthorizer}, + }; + + fn open_mac_dag_state(committee: Arc, path: &std::path::Path) -> DagState { + let registry = Registry::new(); + let (metrics, _reporter) = Metrics::new( + ®istry, + Some(committee.as_ref()), + Some("starfish-mac"), + None, + ); + DagState::open( + 0, + path, + metrics, + committee, + "honest".to_string(), + "starfish-mac".to_string(), + &StorageBackend::Rocksdb, + false, + DisseminationMode::ProtocolDefault, + ) + .dag_state + } + + fn open_starfish_rbc_state(committee: Arc, path: &std::path::Path) -> DagState { + let registry = Registry::new(); + let (metrics, _reporter) = Metrics::new( + ®istry, + Some(committee.as_ref()), + Some("starfish-rbc"), + None, + ); + DagState::open( + 0, + path, + metrics, + committee, + "honest".to_string(), + "starfish-rbc".to_string(), + &StorageBackend::Rocksdb, + false, + DisseminationMode::ProtocolDefault, + ) + .dag_state + } + + fn make_mac_block( + keyrings: &[Vec], + authority: AuthorityIndex, + round: RoundNumber, + parents: Vec, + ) -> VerifiedBlock { + let mut block = VerifiedBlock::new_with_authorizer_and_unprovable( + authority, + round, + parents, + None, + Vec::new(), + 0, + &BlockAuthorizer::MacVector(&keyrings[authority as usize]), + None, + None, + Vec::new(), + Vec::new(), + None, + ConsensusProtocol::Starfish, + None, + None, + None, + None, + None, + None, + None, + ); + block.preserialize(); + block + } + + #[test] + fn block_manager_upgrades_stored_batched_and_pending_mac_copies() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = crypto::mac_keyrings_for_test(committee.len()); + let temp_dir = TempDir::new().unwrap(); + let dag_state = open_mac_dag_state(committee.clone(), temp_dir.path()); + let mut manager = BlockManager::new(dag_state.clone(), &committee); + let genesis: Vec<_> = committee + .authorities() + .map(|authority| BlockReference::new_test(authority, 0)) + .collect(); + + // A stored tag-only copy is upgraded when the author's full vector + // arrives later, without reporting another newly processed block. + let full = make_mac_block(&keyrings, 1, 1, genesis.clone()); + let reference = *full.reference(); + let mut tagged = full.with_recipient_mac(0).unwrap(); + tagged.preserialize(); + assert_eq!( + manager + .add_blocks(vec![Data::new(tagged)], DataSource::BlockBundleStreaming,) + .0 + .len(), + 1 + ); + assert!( + manager + .add_blocks(vec![Data::new(full)], DataSource::BlockBundleStreaming,) + .0 + .is_empty() + ); + assert!( + dag_state + .get_storage_block(reference) + .unwrap() + .has_full_mac_vector() + ); + + // The same upgrade also works when both copies share one receive + // batch and when the block is waiting on a missing parent. + let parent = make_mac_block(&keyrings, 2, 1, genesis); + let child = make_mac_block(&keyrings, 2, 2, vec![*parent.reference()]); + let child_reference = *child.reference(); + let mut tagged_child = child.with_recipient_mac(0).unwrap(); + tagged_child.preserialize(); + manager.add_blocks( + vec![Data::new(tagged_child), Data::new(child)], + DataSource::BlockBundleStreaming, + ); + assert_eq!(manager.pending_blocks_count(), 1); + manager.add_blocks(vec![Data::new(parent)], DataSource::BlockBundleStreaming); + assert_eq!(manager.pending_blocks_count(), 0); + assert!( + dag_state + .get_storage_block(child_reference) + .unwrap() + .has_full_mac_vector() + ); + } + + #[test] + fn starfish_rbc_requests_ack_only_dependencies_and_deduplicates_parent_overlap() { + let committee = Committee::new_for_benchmarks(4); + let temp_dir = TempDir::new().unwrap(); + let dag_state = open_starfish_rbc_state(committee.clone(), temp_dir.path()); + let mut manager = BlockManager::new(dag_state, &committee); + let genesis: Vec<_> = committee + .authorities() + .map(|authority| BlockReference::new_test(authority, 0)) + .collect(); + + let mut ack_target = + VerifiedBlock::new_starfish_rbc(1, 1, genesis.clone(), Vec::new(), 1, Vec::new(), None); + ack_target.preserialize(); + let ack_target_ref = *ack_target.reference(); + + // The target is both a parent and an acknowledgment. It must create + // one waiting edge, while the ack-only target below proves that RBC + // expands the dependency set beyond causal parents. + let mut ack_only = + VerifiedBlock::new_starfish_rbc(2, 1, genesis, Vec::new(), 2, Vec::new(), None); + ack_only.preserialize(); + let ack_only_ref = *ack_only.reference(); + let mut parent_a = VerifiedBlock::new_starfish_rbc( + 0, + 1, + committee + .authorities() + .map(|authority| BlockReference::new_test(authority, 0)) + .collect(), + Vec::new(), + 3, + Vec::new(), + None, + ); + parent_a.preserialize(); + let mut parent_b = VerifiedBlock::new_starfish_rbc( + 3, + 1, + committee + .authorities() + .map(|authority| BlockReference::new_test(authority, 0)) + .collect(), + Vec::new(), + 4, + Vec::new(), + None, + ); + parent_b.preserialize(); + let parent_a_ref = *parent_a.reference(); + let parent_b_ref = *parent_b.reference(); + manager.add_blocks( + vec![Data::new(parent_a), Data::new(parent_b)], + DataSource::BlockHeaderRequest, + ); + let mut child = VerifiedBlock::new_starfish_rbc( + 0, + 2, + vec![ack_target_ref, parent_a_ref, parent_b_ref], + vec![ack_target_ref, ack_only_ref], + 2, + Vec::new(), + None, + ); + child.preserialize(); + + let (_, _, missing) = + manager.add_blocks(vec![Data::new(child)], DataSource::BlockBundleStreaming); + assert!(missing.contains(&ack_target_ref)); + assert!(missing.contains(&ack_only_ref)); + assert_eq!(manager.pending_blocks_count(), 1); + + manager.add_blocks(vec![Data::new(ack_target)], DataSource::BlockHeaderRequest); + assert_eq!( + manager.pending_blocks_count(), + 1, + "the ack-only dependency must keep the child suspended" + ); + manager.add_blocks(vec![Data::new(ack_only)], DataSource::BlockHeaderRequest); + assert_eq!(manager.pending_blocks_count(), 0); + } +} diff --git a/crates/starfish-core/src/broadcaster.rs b/crates/starfish-core/src/broadcaster.rs index d7770fc3..2f2f1a83 100644 --- a/crates/starfish-core/src/broadcaster.rs +++ b/crates/starfish-core/src/broadcaster.rs @@ -21,7 +21,7 @@ use crate::{ dag_state::{ByzantineStrategy, ConsensusProtocol, DataSource}, data::Data, metrics::{Metrics, UtilizationTimerVecExt}, - net_sync::NetworkSyncerInner, + net_sync::{NetworkSyncerInner, prepare_forwarded_blocks_for_peer}, network::{BlockBatch, NetworkMessage, ShardPayload}, runtime::{Handle, sleep}, syncer::CommitObserver, @@ -87,6 +87,8 @@ impl BroadcasterParameters { causal_push_shard_round_lag, }, ConsensusProtocol::Starfish + | ConsensusProtocol::StarfishRbc + | ConsensusProtocol::StarfishRbcSingleDag | ConsensusProtocol::StarfishSpeed | ConsensusProtocol::StarfishBls | ConsensusProtocol::CordialMiners @@ -379,6 +381,8 @@ where match self.inner.dag_state.consensus_protocol { ConsensusProtocol::Starfish + | ConsensusProtocol::StarfishRbc + | ConsensusProtocol::StarfishRbcSingleDag | ConsensusProtocol::StarfishSpeed | ConsensusProtocol::StarfishBls | ConsensusProtocol::SparseStarfishSpeed => { @@ -430,6 +434,12 @@ where .inner .dag_state .get_transmission_parts(&refs_to_send, &refs_to_send); + let headers = prepare_forwarded_blocks_for_peer( + self.inner.dag_state.block_authentication_scheme, + self.inner.dag_state.consensus_protocol, + peer_id, + headers, + ); { let mut sent = self.sent_to_peer.write(); for block in headers.iter() { @@ -466,6 +476,12 @@ where .into_iter() .flatten() .collect(); + let all_blocks = prepare_forwarded_blocks_for_peer( + self.inner.dag_state.block_authentication_scheme, + self.inner.dag_state.consensus_protocol, + peer_id, + all_blocks, + ); let chunk_size = batch_block_size.max(1); // MissingParentsRequest responses must serve the entire requested @@ -869,6 +885,11 @@ where *round = max(*round, block.round()); } } + if inner.dag_state.consensus_protocol.is_starfish_rbc() { + // INIT is the sole proactive header carrier and co-carries direct + // transaction data without repeating the header. + return Some(()); + } tracing::debug!("Blocks to be sent to {peer} are {blocks:?}"); let batch = BlockBatch::full_only(DataSource::BlockBundleStreaming, blocks); if let Ok(size) = bincode::serialized_size(&batch) { @@ -919,6 +940,8 @@ struct PushBatchParts { fn push_transport_format(consensus_protocol: ConsensusProtocol) -> PushOtherBlocksFormat { match consensus_protocol { ConsensusProtocol::Starfish + | ConsensusProtocol::StarfishRbc + | ConsensusProtocol::StarfishRbcSingleDag | ConsensusProtocol::StarfishSpeed | ConsensusProtocol::StarfishBls | ConsensusProtocol::SparseStarfishSpeed => PushOtherBlocksFormat::HeadersAndShards, @@ -1073,6 +1096,12 @@ where let useful_shards = AuthoritySet::default(); report_useful_authorities(metrics, peer.as_str(), useful_headers, useful_shards); + if inner.dag_state.consensus_protocol.is_starfish_rbc() { + let mut sent = sent_to_peer.write(); + sent.extend(blocks.iter().map(|block| *block.reference())); + return Some(()); + } + tracing::debug!("Blocks to be sent to {peer} are {blocks:?}"); let batch = BlockBatch { source: DataSource::BlockBundleStreaming, @@ -1308,6 +1337,7 @@ where fn materialize_push_batch( inner: &Arc>, + to_whom_authority_index: AuthorityIndex, plan: PushBatchParts, ) -> BlockBatch where @@ -1317,13 +1347,19 @@ where match push_transport_format(inner.dag_state.consensus_protocol) { PushOtherBlocksFormat::FullBlocks => { let mut full_blocks = plan.own_blocks; - full_blocks.extend( - inner - .dag_state - .get_transmission_blocks(&plan.other_refs) - .into_iter() - .flatten(), + let other_blocks = inner + .dag_state + .get_transmission_blocks(&plan.other_refs) + .into_iter() + .flatten() + .collect(); + let other_blocks = prepare_forwarded_blocks_for_peer( + inner.dag_state.block_authentication_scheme, + inner.dag_state.consensus_protocol, + to_whom_authority_index, + other_blocks, ); + full_blocks.extend(other_blocks); BlockBatch { source: DataSource::BlockBundleStreaming, full_blocks, @@ -1334,12 +1370,28 @@ where } } PushOtherBlocksFormat::HeadersAndShards => { + let rbc_payload_sidecar = inner.dag_state.consensus_protocol.is_starfish_rbc(); + let header_refs = if rbc_payload_sidecar { + &[] + } else { + plan.other_refs.as_slice() + }; let (headers, shards) = inner .dag_state - .get_transmission_parts(&plan.other_refs, &plan.shard_refs); + .get_transmission_parts(header_refs, &plan.shard_refs); + let headers = prepare_forwarded_blocks_for_peer( + inner.dag_state.block_authentication_scheme, + inner.dag_state.consensus_protocol, + to_whom_authority_index, + headers, + ); BlockBatch { source: DataSource::BlockBundleStreaming, - full_blocks: plan.own_blocks, + full_blocks: if rbc_payload_sidecar { + Vec::new() + } else { + plan.own_blocks + }, headers, shards, useful_headers_authors: plan.useful_headers, @@ -1379,7 +1431,7 @@ where if let Some(max_round) = own_blocks.iter().map(|b| b.round()).max() { *round = max_round; } - if !own_blocks.is_empty() { + if !own_blocks.is_empty() && !inner.dag_state.consensus_protocol.is_starfish_rbc() { let fast_batch = BlockBatch::full_only(DataSource::BlockBundleStreaming, own_blocks.clone()); if let Ok(size) = bincode::serialized_size(&fast_batch) { @@ -1416,8 +1468,14 @@ where // Drop own blocks from the plan — already shipped in the fast batch. plan.own_blocks = Vec::new(); + if inner.dag_state.consensus_protocol.is_starfish_rbc() { + // RBC INIT/recovery owns header dissemination. The ordinary Starfish + // broadcaster remains responsible only for shard sidecars. + plan.other_refs.clear(); + plan.useful_headers = AuthoritySet::default(); + } - let slow_batch = materialize_push_batch(&inner, plan); + let slow_batch = materialize_push_batch(&inner, to_whom_authority_index, plan); if slow_batch.is_empty() { return Some(()); } @@ -1526,7 +1584,11 @@ impl BlockFetcherWorker { #[cfg(test)] mod tests { use super::*; - use crate::committee::Committee; + use crate::{ + committee::Committee, + crypto::{SignatureBytes, mac_keyrings_for_test}, + types::{BaseTransaction, BlockAuthentication, BlockAuthenticationScheme}, + }; fn holder_set(authorities: &[AuthorityIndex]) -> StakeAggregator { let committee = Committee::new_test(vec![1, 1, 1, 1]); @@ -1574,4 +1636,66 @@ mod tests { assert_eq!(ramp_up_chain_bomb_release_probability(180.0), 1.0); assert_eq!(ramp_up_chain_bomb_release_probability(240.0), 1.0); } + + #[test] + fn relay_preparation_selects_recipient_tag_and_stops_after_one_hop() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = mac_keyrings_for_test(committee.len()); + let mut block = VerifiedBlock::new( + 0, + 1, + Vec::new(), + Vec::new(), + 0, + SignatureBytes::default(), + Vec::::new(), + None, + None, + None, + None, + ); + let tags: Vec<_> = keyrings[0] + .iter() + .enumerate() + .map(|(recipient, key)| { + key.compute_tag(0, recipient as AuthorityIndex, &block.digest()) + }) + .collect(); + let expected = tags[2]; + block.header.authentication = BlockAuthentication::MacVector(tags); + let mut rbc_header = block.clone(); + rbc_header.header.authentication = BlockAuthentication::None; + + let relayed = prepare_forwarded_blocks_for_peer( + BlockAuthenticationScheme::MacVector, + ConsensusProtocol::Starfish, + 2, + vec![Data::new(block)], + ); + assert_eq!(relayed.len(), 1); + assert!(matches!( + relayed[0].authentication(), + BlockAuthentication::MacTag(tag) if *tag == expected + )); + + let second_hop = prepare_forwarded_blocks_for_peer( + BlockAuthenticationScheme::MacVector, + ConsensusProtocol::Starfish, + 3, + relayed, + ); + assert!(second_hop.is_empty()); + + let forwarded_rbc_header = prepare_forwarded_blocks_for_peer( + BlockAuthenticationScheme::MacVector, + ConsensusProtocol::StarfishRbc, + 3, + vec![Data::new(rbc_header)], + ); + assert_eq!(forwarded_rbc_header.len(), 1); + assert!(matches!( + forwarded_rbc_header[0].authentication(), + BlockAuthentication::None + )); + } } diff --git a/crates/starfish-core/src/committee.rs b/crates/starfish-core/src/committee.rs index fe8a3bc8..8518d758 100644 --- a/crates/starfish-core/src/committee.rs +++ b/crates/starfish-core/src/committee.rs @@ -10,7 +10,11 @@ use serde::{Deserialize, Serialize}; use crate::{ config::ImportExport, - crypto::{BlsPublicKey, BlsSigner, PublicKey, Signer, dummy_bls_public_key, dummy_public_key}, + crypto::{ + BlsPublicKey, BlsSigner, MlDsa44PublicKey, MlDsa44Signer, MlDsa65PublicKey, MlDsa65Signer, + PublicKey, Signer, dummy_bls_public_key, dummy_ml_dsa_44_public_key, + dummy_ml_dsa_65_public_key, dummy_public_key, + }, data::Data, types::{AuthorityIndex, AuthoritySet, RoundNumber, Stake, VerifiedBlock}, }; @@ -143,6 +147,18 @@ impl Committee { .map(Authority::bls_public_key) } + pub fn get_ml_dsa_44_public_key(&self, authority: AuthorityIndex) -> Option<&MlDsa44PublicKey> { + self.authorities + .get(authority as usize) + .map(Authority::ml_dsa_44_public_key) + } + + pub fn get_ml_dsa_65_public_key(&self, authority: AuthorityIndex) -> Option<&MlDsa65PublicKey> { + self.authorities + .get(authority as usize) + .map(Authority::ml_dsa_65_public_key) + } + pub fn known_authority(&self, authority: AuthorityIndex) -> bool { (authority as usize) < self.len() } @@ -206,15 +222,23 @@ impl Committee { pub fn new_for_benchmarks(committee_size: usize) -> Arc { let signers = Signer::new_for_test(committee_size); let bls_signers = BlsSigner::new_for_test(committee_size); + let ml_dsa_44_signers = MlDsa44Signer::new_for_test(committee_size); + let ml_dsa_65_signers = MlDsa65Signer::new_for_test(committee_size); Self::new( signers .into_iter() .zip(bls_signers) - .map(|(keypair, bls_keypair)| Authority { - stake: 1, - public_key: keypair.public_key(), - bls_public_key: bls_keypair.public_key(), - }) + .zip(ml_dsa_44_signers) + .zip(ml_dsa_65_signers) + .map( + |(((keypair, bls_keypair), ml_dsa_44_keypair), ml_dsa_65_keypair)| Authority { + stake: 1, + public_key: keypair.public_key(), + bls_public_key: bls_keypair.public_key(), + ml_dsa_44_public_key: ml_dsa_44_keypair.public_key(), + ml_dsa_65_public_key: ml_dsa_65_keypair.public_key(), + }, + ) .collect(), ) } @@ -225,6 +249,8 @@ pub struct Authority { stake: Stake, public_key: PublicKey, bls_public_key: BlsPublicKey, + ml_dsa_44_public_key: MlDsa44PublicKey, + ml_dsa_65_public_key: MlDsa65PublicKey, } impl Authority { @@ -233,6 +259,8 @@ impl Authority { stake, public_key: dummy_public_key(), bls_public_key: dummy_bls_public_key(), + ml_dsa_44_public_key: dummy_ml_dsa_44_public_key(), + ml_dsa_65_public_key: dummy_ml_dsa_65_public_key(), } } @@ -247,6 +275,14 @@ impl Authority { pub fn bls_public_key(&self) -> &BlsPublicKey { &self.bls_public_key } + + pub fn ml_dsa_44_public_key(&self) -> &MlDsa44PublicKey { + &self.ml_dsa_44_public_key + } + + pub fn ml_dsa_65_public_key(&self) -> &MlDsa65PublicKey { + &self.ml_dsa_65_public_key + } } impl ImportExport for Committee {} diff --git a/crates/starfish-core/src/config.rs b/crates/starfish-core/src/config.rs index 38804fe9..58d403a6 100644 --- a/crates/starfish-core/src/config.rs +++ b/crates/starfish-core/src/config.rs @@ -9,10 +9,14 @@ use std::{ time::Duration, }; +use rand::{RngCore, rngs::OsRng}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use crate::{ - crypto::{BlsPublicKey, BlsSigner, Signer, dummy_bls_signer, dummy_signer}, + crypto::{ + BlsPublicKey, BlsSigner, MacKey, MlDsa44Signer, MlDsa65Signer, Signer, dummy_bls_signer, + dummy_ml_dsa_44_signer, dummy_ml_dsa_65_signer, dummy_signer, mac_keyrings_for_test, + }, types::{AuthorityIndex, PublicKey, RoundNumber}, }; @@ -54,6 +58,22 @@ pub struct NodeParameters { pub bls_verification_workers: usize, #[serde(default)] pub dissemination_mode: DisseminationMode, + /// Block signature scheme. `None` selects Ed25519. Experimental MAC + /// protocols select their authentication through the consensus name. + #[serde(default)] + pub block_authentication: Option, + /// Fresh, nonzero execution identifier shared by every validator in one + /// Starfish-RBC run. It is generated by benchmark setup and ignored by all + /// other protocols. + #[serde(default)] + pub starfish_rbc_protocol_instance: Option<[u8; 32]>, + /// Testbed-only receiver-local single-DAG RBC path: deliver an exact header + /// after locally observing quorum ECHO rather than quorum READY. Quorum + /// intersection preserves a unique value, but pairwise-MAC testimony is + /// not transferable and selective Byzantine withholding can violate + /// totality. This must remain an explicit benchmark flag. + #[serde(default)] + pub starfish_rbc_single_dag_echo_qc_fast_path: bool, #[serde(default = "node_defaults::default_causal_push_shard_round_lag")] pub causal_push_shard_round_lag: RoundNumber, #[serde( @@ -127,6 +147,9 @@ impl Default for NodeParameters { compress_network: node_defaults::default_compress_network(), bls_verification_workers: node_defaults::default_bls_verification_workers(), dissemination_mode: DisseminationMode::default(), + block_authentication: None, + starfish_rbc_protocol_instance: None, + starfish_rbc_single_dag_echo_qc_fast_path: false, causal_push_shard_round_lag: node_defaults::default_causal_push_shard_round_lag(), enable_strong_vote_adaptive_acknowledgments: node_defaults::default_enable_strong_vote_adaptive_acknowledgments(), @@ -142,6 +165,24 @@ impl NodeParameters { ..Self::default() } } + + /// Replace the Starfish-RBC execution identifier with fresh OS randomness. + /// + /// The orchestrator calls this once before serializing the shared node + /// parameters, so independently generated benchmark files on remote hosts + /// still contain the same identifier. + pub fn refresh_starfish_rbc_protocol_instance(&mut self) -> [u8; 32] { + let mut rng = OsRng; + let protocol_instance = loop { + let mut bytes = [0; 32]; + rng.fill_bytes(&mut bytes); + if bytes != [0; 32] { + break bytes; + } + }; + self.starfish_rbc_protocol_instance = Some(protocol_instance); + protocol_instance + } } impl ImportExport for NodeParameters {} @@ -270,6 +311,9 @@ pub struct NodePrivateConfig { authority: AuthorityIndex, pub keypair: Signer, pub bls_keypair: BlsSigner, + pub ml_dsa_44_keypair: MlDsa44Signer, + pub ml_dsa_65_keypair: MlDsa65Signer, + pub mac_keys: Vec, pub storage_path: PathBuf, } @@ -279,6 +323,9 @@ impl NodePrivateConfig { authority: index, keypair: dummy_signer(), bls_keypair: dummy_bls_signer(), + ml_dsa_44_keypair: dummy_ml_dsa_44_signer(), + ml_dsa_65_keypair: dummy_ml_dsa_65_signer(), + mac_keys: Vec::new(), storage_path: PathBuf::from("storage"), } } @@ -286,20 +333,34 @@ impl NodePrivateConfig { pub fn new_for_benchmarks(working_dir: &Path, committee_size: usize) -> Vec { let signers = Signer::new_for_test(committee_size); let bls_signers = BlsSigner::new_for_test(committee_size); + let ml_dsa_44_signers = MlDsa44Signer::new_for_test(committee_size); + let ml_dsa_65_signers = MlDsa65Signer::new_for_test(committee_size); + let mac_keyrings = mac_keyrings_for_test(committee_size); signers .into_iter() .zip(bls_signers) + .zip(ml_dsa_44_signers) + .zip(ml_dsa_65_signers) + .zip(mac_keyrings) .enumerate() - .map(|(i, (keypair, bls_keypair))| { - let authority = i as AuthorityIndex; - let path = working_dir.join(NodePrivateConfig::default_storage_path(authority)); - Self { - authority, - keypair, - bls_keypair, - storage_path: path, - } - }) + .map( + |( + i, + ((((keypair, bls_keypair), ml_dsa_44_keypair), ml_dsa_65_keypair), mac_keys), + )| { + let authority = i as AuthorityIndex; + let path = working_dir.join(NodePrivateConfig::default_storage_path(authority)); + Self { + authority, + keypair, + bls_keypair, + ml_dsa_44_keypair, + ml_dsa_65_keypair, + mac_keys, + storage_path: path, + } + }, + ) .collect() } @@ -326,6 +387,29 @@ impl NodePrivateConfig { impl ImportExport for NodePrivateConfig {} +#[cfg(test)] +mod tests { + use super::NodeParameters; + + #[test] + fn starfish_rbc_protocol_instance_is_optional_and_roundtrips() { + let mut parameters: NodeParameters = serde_yaml::from_str("{}").unwrap(); + assert_eq!(parameters.starfish_rbc_protocol_instance, None); + assert!(!parameters.starfish_rbc_single_dag_echo_qc_fast_path); + + let protocol_instance = parameters.refresh_starfish_rbc_protocol_instance(); + assert_ne!(protocol_instance, [0; 32]); + + let yaml = serde_yaml::to_string(¶meters).unwrap(); + let decoded: NodeParameters = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!( + decoded.starfish_rbc_protocol_instance, + Some(protocol_instance) + ); + assert!(!decoded.starfish_rbc_single_dag_echo_qc_fast_path); + } +} + /// How transaction payloads are filled by the generator. #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/crates/starfish-core/src/consensus/base_committer.rs b/crates/starfish-core/src/consensus/base_committer.rs index bd47efa0..7f42db59 100644 --- a/crates/starfish-core/src/consensus/base_committer.rs +++ b/crates/starfish-core/src/consensus/base_committer.rs @@ -166,6 +166,7 @@ impl BaseCommitter { let potential_certificates: Vec<_> = certifying_blocks .iter() .filter(|block| reachable.contains(block.reference())) + .filter(|block| self.is_consensus_evidence(block)) .collect(); // Use those potential certificates to determine which (if any) of the target @@ -238,7 +239,10 @@ impl BaseCommitter { ) -> bool { let voting_blocks = self.dag_state.get_blocks_by_round_cached(voting_round); let mut blame_stake_aggregator = StakeAggregator::::new(); - for voting_block in voting_blocks.iter() { + for voting_block in voting_blocks + .iter() + .filter(|block| self.is_consensus_evidence(block)) + { let voter = voting_block.authority(); blame_stake_aggregator.add(voter, &self.committee); } @@ -256,7 +260,10 @@ impl BaseCommitter { for leader_block in &leader_blocks { let mut vote_stake_aggregator = StakeAggregator::::new(); let leader_block_reference = leader_block.reference(); - for voting_block in voting_blocks.iter() { + for voting_block in voting_blocks + .iter() + .filter(|block| self.is_consensus_evidence(block)) + { let voter = voting_block.authority(); if voter_info .voters @@ -292,7 +299,10 @@ impl BaseCommitter { // Quickly reject if there isn't enough stake to support the leader from // the potential certificates. let mut early_stop = true; - for certifying_block in certifying_blocks.iter() { + for certifying_block in certifying_blocks + .iter() + .filter(|block| self.is_consensus_evidence(block)) + { if total_stake_aggregator.add(certifying_block.authority(), &self.committee) { early_stop = false; break; @@ -310,6 +320,7 @@ impl BaseCommitter { self.has_quorum_support( certifying_blocks .iter() + .filter(|block| self.is_consensus_evidence(block)) .filter(|b| self.is_certificate(b, leader_block, voter_info)) .map(|b| b.authority()), ) @@ -419,13 +430,21 @@ impl BaseCommitter { } fn requires_clean_leader_for_commit(&self) -> bool { - self.dag_state.consensus_protocol.is_sailfish_pp() + self.dag_state.consensus_protocol.is_starfish_rbc() + || self.dag_state.consensus_protocol.is_sailfish_pp() || self .dag_state .consensus_protocol .carries_unprovable_certificate() } + /// Starfish-RBC dirty headers exist only for dependency fetching and RBC + /// progress. They cannot vote, blame, or certify a leader. + fn is_consensus_evidence(&self, block: &VerifiedBlock) -> bool { + !self.dag_state.consensus_protocol.is_starfish_rbc() + || self.dag_state.has_clean_vertex(block.reference()) + } + /// Apply the indirect decision rule to the specified leader /// to see whether we can indirect-commit or indirect-skip it. #[tracing::instrument(skip_all, fields(leader = %format_authority_round(leader, leader_round)))] @@ -527,3 +546,97 @@ impl Display for BaseCommitter { write!(f, "Committer-Round-Offset{}", self.options.round_offset) } } + +#[cfg(test)] +mod tests { + use ahash::{AHashMap, AHashSet}; + use prometheus::Registry; + use tempfile::TempDir; + + use super::*; + use crate::{ + config::{DisseminationMode, StorageBackend}, + dag_state::DataSource, + metrics::Metrics, + }; + + #[test] + fn starfish_rbc_dirty_voters_cannot_force_a_skip() { + let committee = Committee::new_for_benchmarks(4); + let registry = Registry::new(); + let (metrics, _reporter) = Metrics::new( + ®istry, + Some(committee.as_ref()), + Some("starfish-rbc"), + None, + ); + let dir = TempDir::new().unwrap(); + let dag_state = DagState::open( + 0, + dir.path(), + metrics, + committee.clone(), + "honest".to_string(), + "starfish-rbc".to_string(), + &StorageBackend::Rocksdb, + false, + DisseminationMode::ProtocolDefault, + ) + .dag_state; + let genesis: Vec<_> = committee + .authorities() + .map(|authority| BlockReference::new_test(authority, 0)) + .collect(); + let mut parent_blocks = Vec::new(); + for authority in [0, 1, 2] { + let mut block = VerifiedBlock::new_starfish_rbc( + authority, + 1, + genesis.clone(), + Vec::new(), + authority as u64, + Vec::new(), + None, + ); + block.preserialize(); + parent_blocks.push(Data::new(block)); + } + let parent_refs: Vec<_> = parent_blocks + .iter() + .map(|block| *block.reference()) + .collect(); + dag_state.insert_general_blocks(parent_blocks, DataSource::BlockBundleStreaming); + assert!(dag_state.apply_starfish_rbc_delivery_refs_for_test(&parent_refs)); + + let mut voting_blocks = Vec::new(); + for authority in [0, 2, 3] { + let mut block = VerifiedBlock::new_starfish_rbc( + authority, + 2, + parent_refs.clone(), + Vec::new(), + authority as u64, + Vec::new(), + None, + ); + block.preserialize(); + voting_blocks.push(Data::new(block)); + } + let voting_refs: Vec<_> = voting_blocks + .iter() + .map(|block| *block.reference()) + .collect(); + dag_state.insert_general_blocks(voting_blocks, DataSource::BlockBundleStreaming); + + let committer = BaseCommitter::new(committee.clone(), dag_state.clone()); + let voter_info = VoterInfo { + voters: AHashSet::new(), + voter_strong_votes: AHashMap::new(), + }; + let leader = committee.elect_leader(1); + assert!(!committer.decide_skip(2, leader, &voter_info)); + + assert!(dag_state.apply_starfish_rbc_delivery_refs_for_test(&voting_refs)); + assert!(committer.decide_skip(2, leader, &voter_info)); + } +} diff --git a/crates/starfish-core/src/consensus/linearizer.rs b/crates/starfish-core/src/consensus/linearizer.rs index 3a624b8e..2315a960 100644 --- a/crates/starfish-core/src/consensus/linearizer.rs +++ b/crates/starfish-core/src/consensus/linearizer.rs @@ -219,6 +219,7 @@ impl Linearizer { tracing::debug!("Starting collection with leader {:?}", leader_block); let leader_block_ref = *leader_block.reference(); let min_round = leader_block_ref.round.saturating_sub(MAX_TRAVERSAL_DEPTH); + let clean_only = dag_state.consensus_protocol.is_starfish_rbc(); let mut committed_ack_refs = BTreeSet::new(); let mut current_level = vec![leader_block]; @@ -228,11 +229,24 @@ impl Linearizer { while !current_level.is_empty() { let mut next_refs = Vec::new(); for x in ¤t_level { + if clean_only && !dag_state.has_clean_vertex(x.reference()) { + tracing::warn!( + "Ignoring dirty Starfish-RBC block {} at the linearization boundary", + x.reference() + ); + continue; + } let who_votes = x.authority(); for ack_ref in self.effective_acknowledgments(dag_state, x) { if ack_ref.round < min_round { continue; } + if clean_only && !dag_state.has_clean_vertex(&ack_ref) { + tracing::warn!( + "Ignoring dirty Starfish-RBC acknowledgment target {ack_ref}" + ); + continue; + } if direct_ack { if ack_ref.authority != x.authority() { continue; @@ -247,7 +261,10 @@ impl Linearizer { } self.traversed_blocks.insert(*x.reference()); for reference in x.block_references() { - if reference.round >= min_round && self.traversed_blocks.insert(*reference) { + if reference.round >= min_round + && (!clean_only || dag_state.has_clean_vertex(reference)) + && self.traversed_blocks.insert(*reference) + { next_refs.push(*reference); } } @@ -265,6 +282,7 @@ impl Linearizer { // Phase 2: batch-fetch the newly committed ack refs. let new_ack_refs: Vec<_> = committed_ack_refs .into_iter() + .filter(|reference| !clean_only || dag_state.has_clean_vertex(reference)) .filter(|r| self.committed.insert(*r)) .collect(); @@ -360,6 +378,15 @@ impl Linearizer { let consensus_protocol = dag_state.consensus_protocol; let mut committed = vec![]; for (leader_block, metastate) in committed_leaders { + if consensus_protocol.is_starfish_rbc() + && !dag_state.has_clean_vertex(leader_block.reference()) + { + tracing::warn!( + "Ignoring dirty Starfish-RBC leader {} at the linearization boundary", + leader_block.reference() + ); + continue; + } // Collect the sub-dag generated using each of these leaders as anchor. let leader_ref = *leader_block.reference(); let leader_acks = leader_block.acknowledgments(); @@ -370,6 +397,8 @@ impl Linearizer { self.collect_subdag_acknowledgments(dag_state, leader_block, true) } ConsensusProtocol::Starfish + | ConsensusProtocol::StarfishRbc + | ConsensusProtocol::StarfishRbcSingleDag | ConsensusProtocol::StarfishSpeed | ConsensusProtocol::SparseStarfishSpeed => { self.collect_subdag_acknowledgments(dag_state, leader_block, false) @@ -502,6 +531,25 @@ mod tests { Data::new(block) } + fn make_rbc_block( + authority: AuthorityIndex, + round: RoundNumber, + parents: Vec, + acks: Vec, + ) -> Data { + let mut block = VerifiedBlock::new_starfish_rbc( + authority, + round, + parents, + acks, + round as u64, + Vec::new(), + None, + ); + block.preserialize(); + Data::new(block) + } + #[test] fn ssfs_opt_sequences_leader_and_leader_acknowledgments() { let (committee, dag_state) = open_test_dag_state_for("sparse-starfish-speed"); @@ -587,4 +635,52 @@ mod tests { "Std must not directly sequence leader acknowledgments" ); } + + #[test] + fn starfish_rbc_linearizer_ignores_dirty_history_and_sequences_clean_quorum_acks() { + let (committee, dag_state) = open_test_dag_state_for("starfish-rbc"); + let genesis: Vec<_> = (0..4) + .map(|auth| BlockReference::new_test(auth, 0)) + .collect(); + let target = make_rbc_block(0, 1, genesis.clone(), Vec::new()); + let parent_a = make_rbc_block(1, 1, genesis.clone(), Vec::new()); + let parent_b = make_rbc_block(2, 1, genesis, Vec::new()); + let target_ref = *target.reference(); + let round_one_refs = vec![target_ref, *parent_a.reference(), *parent_b.reference()]; + let voters: Vec<_> = (0..3) + .map(|authority| make_rbc_block(authority, 2, round_one_refs.clone(), vec![target_ref])) + .collect(); + let voter_refs: Vec<_> = voters.iter().map(|block| *block.reference()).collect(); + let leader = make_rbc_block(3, 3, voter_refs.clone(), Vec::new()); + let leader_ref = *leader.reference(); + let mut all_blocks = vec![target, parent_a, parent_b]; + all_blocks.extend(voters); + all_blocks.push(leader.clone()); + dag_state.insert_general_blocks(all_blocks, DataSource::BlockBundleStreaming); + + let mut dirty_linearizer = Linearizer::new(committee.clone()); + assert!( + dirty_linearizer + .handle_commit(&dag_state, vec![(leader.clone(), None)]) + .is_empty(), + "a dirty leader cannot enter the Starfish-RBC linearizer" + ); + + let mut delivered = round_one_refs; + delivered.extend(voter_refs); + delivered.push(leader_ref); + assert!(dag_state.apply_starfish_rbc_delivery_refs_for_test(&delivered)); + + let mut clean_linearizer = Linearizer::new(committee); + let committed = clean_linearizer.handle_commit(&dag_state, vec![(leader, None)]); + assert_eq!(committed.len(), 1); + assert!( + committed[0] + .0 + .blocks + .iter() + .any(|block| block.reference() == &target_ref), + "only clean voting headers may form acknowledgment quorum" + ); + } } diff --git a/crates/starfish-core/src/consensus/universal_committer.rs b/crates/starfish-core/src/consensus/universal_committer.rs index 6ea87d25..eeb0f1ea 100644 --- a/crates/starfish-core/src/consensus/universal_committer.rs +++ b/crates/starfish-core/src/consensus/universal_committer.rs @@ -88,6 +88,11 @@ impl UniversalCommitter { let mut voters = AHashSet::new(); let mut voter_strong_votes = AHashMap::new(); for vb in potential_voting_blocks.iter() { + if self.dag_state.consensus_protocol.is_starfish_rbc() + && !self.dag_state.has_clean_vertex(vb.reference()) + { + continue; + } let vb_ref = *vb.reference(); if self.dag_state.consensus_protocol.uses_bls() { if let Some(leader_ref) = @@ -404,6 +409,8 @@ impl UniversalCommitterBuilder { match dag_state.consensus_protocol { ConsensusProtocol::Mysticeti | ConsensusProtocol::Starfish + | ConsensusProtocol::StarfishRbc + | ConsensusProtocol::StarfishRbcSingleDag | ConsensusProtocol::StarfishSpeed | ConsensusProtocol::StarfishBls | ConsensusProtocol::MysticetiBls diff --git a/crates/starfish-core/src/core.rs b/crates/starfish-core/src/core.rs index 2a0311cd..d2fd346d 100644 --- a/crates/starfish-core/src/core.rs +++ b/crates/starfish-core/src/core.rs @@ -2,7 +2,7 @@ // Modifications Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::{mem, sync::Arc}; +use std::{collections::BTreeSet, mem, sync::Arc}; use ahash::{AHashMap, AHashSet}; use reed_solomon_simd::ReedSolomonEncoder; @@ -20,7 +20,9 @@ use crate::{ linearizer::CommittedSubDag, universal_committer::{UniversalCommitter, UniversalCommitterBuilder}, }, - crypto::{self, AsBytes, BlsSignatureBytes, BlsSigner, Signer}, + crypto::{ + self, AsBytes, BlsSignatureBytes, BlsSigner, MacKey, MlDsa44Signer, MlDsa65Signer, Signer, + }, dag_state::{ ByzantineStrategy, CACHED_ROUNDS, CommitData, ConsensusProtocol, DagState, DataSource, OwnBlockData, @@ -32,9 +34,10 @@ use crate::{ state::RecoveredState, store::Store, types::{ - AuthorityIndex, AuthoritySet, BaseTransaction, BlockReference, BlsAggregateCertificate, - Encoder, PartialSig, PartialSigKind, ProvableShard, ReconstructedTransactionData, - RoundNumber, SailfishFields, Shard, VerifiedBlock, + AuthorityIndex, AuthoritySet, BaseTransaction, BlockAuthenticationScheme, BlockAuthorizer, + BlockReference, BlsAggregateCertificate, Encoder, PartialSig, PartialSigKind, + ProvableShard, ReconstructedTransactionData, RoundNumber, SailfishFields, Shard, + StarfishRbcFieldsV3, StarfishRbcReferenceV3, VerifiedBlock, }, }; @@ -49,6 +52,9 @@ pub struct Core { block_manager: BlockManager, pending: Vec, pending_reconstructed_data: AHashMap, + /// Irrevocable local ECHO/READY statements waiting to ride on the next + /// ordinary block in the single-DAG protocol. + pending_starfish_rbc_references: BTreeSet, // For Byzantine node, last_own_block contains a vector of blocks last_own_block: Vec, block_handler: H, @@ -60,6 +66,9 @@ pub struct Core { pub(crate) metrics: Arc, signer: Signer, bls_signer: BlsSigner, + ml_dsa_44_signer: MlDsa44Signer, + ml_dsa_65_signer: MlDsa65Signer, + mac_keys: Arc>, partial_sig_outbox: Option>, // todo - ugly, probably need to merge syncer and core recovered_committed_blocks: Option>, @@ -176,6 +185,7 @@ impl Core { store, pending, pending_reconstructed_data: AHashMap::new(), + pending_starfish_rbc_references: BTreeSet::new(), last_own_block: vec![last_own_block], block_handler, authority, @@ -185,6 +195,9 @@ impl Core { metrics, signer: private_config.keypair, bls_signer: private_config.bls_keypair, + ml_dsa_44_signer: private_config.ml_dsa_44_keypair, + ml_dsa_65_signer: private_config.ml_dsa_65_keypair, + mac_keys: Arc::new(private_config.mac_keys), partial_sig_outbox, recovered_committed_blocks: Some(committed_blocks), recovered_committed_leaders_count: Some(committed_leaders_count), @@ -206,6 +219,28 @@ impl Core { &self.signer } + pub(crate) fn add_starfish_rbc_reference(&mut self, reference: StarfishRbcReferenceV3) { + assert!( + self.dag_state + .consensus_protocol + .is_starfish_rbc_single_dag(), + "embedded RBC references require single-DAG Starfish-RBC" + ); + self.pending_starfish_rbc_references.insert(reference); + } + + pub(crate) fn get_ml_dsa_44_signer(&self) -> &crate::crypto::MlDsa44Signer { + &self.ml_dsa_44_signer + } + + pub(crate) fn get_ml_dsa_65_signer(&self) -> &crate::crypto::MlDsa65Signer { + &self.ml_dsa_65_signer + } + + pub fn mac_keys(&self) -> Arc> { + self.mac_keys.clone() + } + pub fn get_universal_committer(&self) -> UniversalCommitter { self.committer.clone() } @@ -486,6 +521,7 @@ impl Core { // Dual-DAG protocols: require clean parent quorum before creating a block. if protocol.uses_dual_dag() + && !protocol.is_starfish_rbc_single_dag() && clock_round > 1 && !self.dag_state.clean_parent_quorum(clock_round - 1) { @@ -498,6 +534,25 @@ impl Core { return None; } + // `build_block` always prepends the creator's previous block. For + // Starfish-RBC that local header is dirty until the local RBC instance + // delivers it; another clean quorum must not let us smuggle this dirty + // mandatory parent into a proposal. + if protocol.is_starfish_rbc() + && !protocol.is_starfish_rbc_single_dag() + && clock_round > 1 + && self + .last_own_block + .iter() + .any(|own| !self.dag_state.has_clean_vertex(own.block.reference())) + { + tracing::debug!( + "Cannot construct Starfish-RBC block in round {}: own previous header is not clean", + clock_round + ); + return None; + } + let voted_leader_ref = if protocol.uses_bls() { self.select_starfish_bls_voted_leader(clock_round) } else { @@ -518,8 +573,17 @@ impl Core { }; let pending_transactions = self.get_pending_transactions(clock_round); - let (mut transactions, block_references, raw_refs) = + let (mut transactions, block_references, raw_refs, deferred_dirty_refs) = self.collect_transactions_and_references(pending_transactions, clock_round); + // A header can reach the dirty DAG before the local RBC instance + // delivers it. Keep its include notification pending so a proposal + // created from some other clean quorum does not permanently consume + // the only chance to reference it once delivery completes. + self.pending.extend( + deferred_dirty_refs + .into_iter() + .map(MetaTransaction::Include), + ); // Dual-DAG protocols: if the clean-parent filter reduced the parent // set below threshold-clock quorum, we cannot build a valid block yet. @@ -635,6 +699,20 @@ impl Core { } else { None }; + let single_dag_rbc = protocol.is_starfish_rbc_single_dag().then(|| { + let maximum = self.committee.len().saturating_mul(6); + let references: Vec<_> = self + .pending_starfish_rbc_references + .iter() + .copied() + .filter(|evidence| evidence.reference().round <= clock_round) + .take(maximum) + .collect(); + for reference in &references { + self.pending_starfish_rbc_references.remove(reference); + } + StarfishRbcFieldsV3::new(references) + }); // Create and store blocks let mut first_block = None; @@ -655,6 +733,7 @@ impl Core { block_id, aggregate_round_sig, certified_leader, + single_dag_rbc.as_ref(), ); tracing::debug!("Created block {:?}", block_data); if first_block.is_none() { @@ -700,6 +779,7 @@ impl Core { Vec, Vec, Vec, + Vec, ) { let mut transactions = Vec::new(); let mut pending_refs = Vec::new(); @@ -711,12 +791,36 @@ impl Core { MetaTransaction::Include(include) => pending_refs.push(include), } } - let raw_refs = pending_refs.clone(); + // Dirty vertices must not even participate in transitive reduction: + // otherwise a dirty child can suppress one of its clean parents and + // then be filtered itself, shrinking the usable clean frontier. + let (compression_candidates, deferred_dirty_refs): (Vec<_>, Vec<_>) = + if self.dag_state.consensus_protocol.is_starfish_rbc() + && !self + .dag_state + .consensus_protocol + .is_starfish_rbc_single_dag() + { + pending_refs.into_iter().partition(|reference| { + reference.round == 0 || self.dag_state.has_clean_vertex(reference) + }) + } else { + (pending_refs, Vec::new()) + }; + // These are the usable inputs that callers must retry when a later + // proposal gate fails. Dirty RBC refs are retried independently above + // so the two retry paths cannot duplicate them. + let raw_refs = compression_candidates.clone(); let mut block_references = - self.compress_pending_block_references(&pending_refs, block_round); + self.compress_pending_block_references(&compression_candidates, block_round); // Dual-DAG protocols: filter parents to only include clean blocks. - if self.dag_state.consensus_protocol.uses_dual_dag() { + if self.dag_state.consensus_protocol.uses_dual_dag() + && !self + .dag_state + .consensus_protocol + .is_starfish_rbc_single_dag() + { let before = block_references.clone(); block_references.retain(|r| r.round == 0 || self.dag_state.has_clean_vertex(r)); let filtered_out_refs: Vec<_> = before @@ -777,11 +881,16 @@ impl Core { seen.contains(self.authority), is_compressed_non_leader ); - return (transactions, vec![], raw_refs); + return (transactions, vec![], raw_refs, deferred_dirty_refs); } } - (transactions, block_references, raw_refs) + ( + transactions, + block_references, + raw_refs, + deferred_dirty_refs, + ) } fn prepare_encoded_transactions( @@ -895,6 +1004,7 @@ impl Core { block_id_in_round: usize, aggregate_round_sig: Option, certified_leader: Option<(BlockReference, BlsAggregateCertificate)>, + single_dag_rbc: Option<&StarfishRbcFieldsV3>, ) -> Data { let time_ns = timestamp_utc().as_nanos() as u64 + block_id_in_round as u64; let own_previous = *self.last_own_block[block_id_in_round].block.reference(); @@ -991,28 +1101,63 @@ impl Core { None }; - let mut block = VerifiedBlock::new_with_signer_and_unprovable( - self.authority, - clock_round, - block_references, - voted_leader_ref, - acknowledgment_references.to_vec(), - time_ns, - &self.signer, - bls_signer_opt, - committee_opt, - aggregate_dac_sigs, - transactions.to_vec(), - encoded_transactions.clone(), - self.dag_state.consensus_protocol, - strong_vote, - aggregate_round_sig, - certified_leader, - precomputed_round_sig, - precomputed_leader_sig, - sailfish_fields, - unprovable_certificate, - ); + let mut block = if protocol.is_starfish_rbc_single_dag() { + VerifiedBlock::new_starfish_rbc_single_dag( + self.authority, + clock_round, + block_references, + acknowledgment_references.to_vec(), + time_ns, + transactions.to_vec(), + encoded_transactions.clone(), + single_dag_rbc + .cloned() + .expect("single-DAG Starfish-RBC block requires V3 fields"), + ) + } else if protocol == ConsensusProtocol::StarfishRbc { + VerifiedBlock::new_starfish_rbc( + self.authority, + clock_round, + block_references, + acknowledgment_references.to_vec(), + time_ns, + transactions.to_vec(), + encoded_transactions.clone(), + ) + } else { + let authorizer = match self.dag_state.block_authentication_scheme { + BlockAuthenticationScheme::Ed25519 => BlockAuthorizer::Ed25519(&self.signer), + BlockAuthenticationScheme::MacVector => BlockAuthorizer::MacVector(&self.mac_keys), + BlockAuthenticationScheme::MlDsa44 => { + BlockAuthorizer::MlDsa44(&self.ml_dsa_44_signer) + } + BlockAuthenticationScheme::MlDsa65 => { + BlockAuthorizer::MlDsa65(&self.ml_dsa_65_signer) + } + }; + VerifiedBlock::new_with_authorizer_and_unprovable( + self.authority, + clock_round, + block_references, + voted_leader_ref, + acknowledgment_references.to_vec(), + time_ns, + &authorizer, + bls_signer_opt, + committee_opt, + aggregate_dac_sigs, + transactions.to_vec(), + encoded_transactions.clone(), + self.dag_state.consensus_protocol, + strong_vote, + aggregate_round_sig, + certified_leader, + precomputed_round_sig, + precomputed_leader_sig, + sailfish_fields, + unprovable_certificate, + ) + }; let role = if is_round_leader { "leader" @@ -1608,6 +1753,26 @@ mod tests { Data::new(block) } + fn make_starfish_rbc_round_1_block( + committee: &Committee, + authority: AuthorityIndex, + ) -> Data { + let mut block = VerifiedBlock::new_starfish_rbc( + authority, + 1, + committee + .authorities() + .map(|auth| BlockReference::new_test(auth, 0)) + .collect(), + Vec::new(), + authority as u64, + Vec::new(), + None, + ); + block.preserialize(); + Data::new(block) + } + fn make_test_round_certificate( bls_signers: &[BlsSigner], round: RoundNumber, @@ -1688,6 +1853,69 @@ mod tests { assert_eq!(refs[0], *round_1.reference()); } + #[test] + fn starfish_rbc_proposal_defers_dirty_include_until_delivery() { + let authority = 0; + let committee = Committee::new_for_benchmarks(4); + let registry = Registry::new(); + let (metrics, _reporter) = Metrics::new( + ®istry, + Some(committee.as_ref()), + Some("starfish-rbc"), + None, + ); + let dir = TempDir::new().unwrap(); + let recovered = DagState::open( + authority, + dir.path(), + metrics.clone(), + committee.clone(), + "honest".to_string(), + "starfish-rbc".to_string(), + &StorageBackend::Rocksdb, + false, + DisseminationMode::ProtocolDefault, + ); + let private_config = NodePrivateConfig::new_for_tests(authority); + let (mut core, _) = Core::open( + NoopBlockHandler, + authority, + committee.clone(), + private_config, + metrics, + recovered, + None, + ); + + let own_round_one = core + .try_new_block("new_blocks") + .expect("round-one block should be creatable"); + let peer_one = make_starfish_rbc_round_1_block(&committee, 1); + let peer_two = make_starfish_rbc_round_1_block(&committee, 2); + let dirty_peer = make_starfish_rbc_round_1_block(&committee, 3); + let dirty_ref = *dirty_peer.reference(); + core.add_headers( + vec![peer_one.clone(), peer_two.clone(), dirty_peer], + DataSource::BlockBundleStreamingHeader, + ); + + assert!( + core.dag_state() + .apply_starfish_rbc_delivery_refs_for_test(&[ + *own_round_one.reference(), + *peer_one.reference(), + *peer_two.reference(), + ]) + ); + let round_two = core + .try_new_block("new_blocks") + .expect("a clean quorum should permit the round-two proposal"); + assert!(!round_two.block_references().contains(&dirty_ref)); + assert!(core.pending.iter().any(|pending| { + matches!(pending, MetaTransaction::Include(reference) if *reference == dirty_ref) + })); + } + #[test] fn mysticeti_bls_non_leader_can_build_round_2_with_prev_leader_parent() { let authority = 0; @@ -1828,6 +2056,7 @@ mod tests { 0, Some(make_test_round_certificate(&bls_signers, 1)), None, + None, ); let refs = round_2.block_references(); diff --git a/crates/starfish-core/src/core_thread/spawned.rs b/crates/starfish-core/src/core_thread/spawned.rs index aefcdb78..450315f9 100644 --- a/crates/starfish-core/src/core_thread/spawned.rs +++ b/crates/starfish-core/src/core_thread/spawned.rs @@ -13,6 +13,7 @@ use crate::{ dag_state::DataSource, data::Data, metrics::{Metrics, UtilizationTimerExt}, + starfish_rbc::PinnedRbcHeader, syncer::{CommitObserver, Syncer, SyncerSignals}, types::{ AuthorityIndex, BlockReference, ProvableShard, ReconstructedTransactionData, RoundNumber, @@ -68,6 +69,9 @@ enum CoreThreadCommand { ApplyCertificateEvents(Vec, oneshot::Sender<()>), /// Apply Sailfish RBC-certified vertices on the core thread. ApplySailfishCertificates(Vec, oneshot::Sender<()>), + /// Apply locally delivered Starfish-RBC headers on the core thread. + ApplyStarfishRbcDeliveries(Vec, oneshot::Sender<()>), + ApplyStarfishRbcReference(crate::types::StarfishRbcReferenceV3, oneshot::Sender<()>), /// Store a Sailfish++ timeout certificate in DagState. ApplyTimeoutCert(SailfishTimeoutCert, oneshot::Sender<()>), /// Store a Sailfish++ no-vote certificate in DagState. @@ -192,6 +196,33 @@ impl, + ) { + let (sender, receiver) = oneshot::channel(); + self.send(CoreThreadCommand::ApplyStarfishRbcDeliveries( + delivered_headers, + sender, + )) + .await; + receiver.await.expect("core thread is not expected to stop"); + } + + pub(crate) async fn apply_starfish_rbc_reference( + &self, + reference: crate::types::StarfishRbcReferenceV3, + ) { + let (sender, receiver) = oneshot::channel(); + self.send(CoreThreadCommand::ApplyStarfishRbcReference( + reference, sender, + )) + .await; + receiver.await.expect("core thread is not expected to stop"); + } /// Store a Sailfish++ timeout certificate on the core thread. pub async fn apply_timeout_cert(&self, cert: SailfishTimeoutCert) { let (sender, receiver) = oneshot::channel(); @@ -375,6 +406,22 @@ impl CoreThread { self.syncer.apply_sailfish_certificates(certified_refs); sender.send(()).ok(); } + CoreThreadCommand::ApplyStarfishRbcDeliveries(delivered_headers, sender) => { + metrics + .core_thread_tasks_total + .with_label_values(&["apply_starfish_rbc_deliveries"]) + .inc(); + self.syncer.apply_starfish_rbc_deliveries(delivered_headers); + sender.send(()).ok(); + } + CoreThreadCommand::ApplyStarfishRbcReference(reference, sender) => { + metrics + .core_thread_tasks_total + .with_label_values(&["apply_starfish_rbc_reference"]) + .inc(); + self.syncer.apply_starfish_rbc_reference(reference); + sender.send(()).ok(); + } CoreThreadCommand::ApplyTimeoutCert(cert, sender) => { metrics .core_thread_tasks_total @@ -477,7 +524,7 @@ mod tests { recovered, None, ); - let syncer = Syncer::new(core, false, NoopCommitObserver, metrics, None, None); + let syncer = Syncer::new(core, false, NoopCommitObserver, metrics, None, None, None); CoreThreadDispatcher::start(syncer) } diff --git a/crates/starfish-core/src/crypto.rs b/crates/starfish-core/src/crypto.rs index 761b2f68..430ee2a3 100644 --- a/crates/starfish-core/src/crypto.rs +++ b/crates/starfish-core/src/crypto.rs @@ -5,8 +5,12 @@ use std::fmt; use blst::min_sig as bls; -use ed25519_consensus::Signature; -use rand::{SeedableRng, rngs::StdRng}; +use ml_dsa::{ + Keypair as _, MlDsa44, MlDsa65, Signature as MlDsaSignature, Signer as MlDsaSignerTrait, + SigningKey as MlDsaSigningKey, Verifier as MlDsaVerifierTrait, + VerifyingKey as MlDsaVerifyingKey, +}; +use rand::{RngCore, SeedableRng, rngs::StdRng}; use rs_merkle::{Hasher, MerkleProof, MerkleTree}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use zeroize::Zeroize; @@ -15,8 +19,8 @@ use crate::{ committee::Committee, crypto, types::{ - AuthorityIndex, AuthoritySet, BaseTransaction, BlockHeader, BlockReference, RoundNumber, - Shard, TimestampNs, + AuthorityIndex, AuthoritySet, BaseTransaction, BlockReference, RoundNumber, Shard, + StarfishRbcFieldsV3, TimestampNs, }, }; @@ -73,6 +77,15 @@ pub fn sailfish_novote_digest(round: RoundNumber, leader: AuthorityIndex) -> [u8 pub const SIGNATURE_SIZE: usize = 64; pub const BLOCK_DIGEST_SIZE: usize = 32; +pub const MAC_KEY_SIZE: usize = 32; +pub const MAC_TAG_SIZE: usize = 32; +pub const ML_DSA_SEED_SIZE: usize = 32; +pub const ML_DSA_44_SEED_SIZE: usize = ML_DSA_SEED_SIZE; +pub const ML_DSA_44_PUBLIC_KEY_SIZE: usize = 1_312; +pub const ML_DSA_44_SIGNATURE_SIZE: usize = 2_420; +pub const ML_DSA_65_SEED_SIZE: usize = ML_DSA_SEED_SIZE; +pub const ML_DSA_65_PUBLIC_KEY_SIZE: usize = 1_952; +pub const ML_DSA_65_SIGNATURE_SIZE: usize = 3_309; pub const TRANSACTIONS_DIGEST_SIZE: usize = 32; @@ -88,6 +101,13 @@ pub struct PublicKey(ed25519_consensus::VerificationKey); #[derive(Clone, Copy, Eq, Ord, PartialOrd, PartialEq, Hash)] pub struct SignatureBytes([u8; SIGNATURE_SIZE]); +/// A pairwise secret key shared by exactly two validators. +#[derive(Clone, Eq, PartialEq)] +pub struct MacKey([u8; MAC_KEY_SIZE]); + +#[derive(Clone, Copy, Ord, PartialOrd)] +pub struct MacTag([u8; MAC_TAG_SIZE]); + // Box ensures value is not copied in memory when Signer itself is moved around // for better security #[derive(Clone)] @@ -175,13 +195,16 @@ impl TransactionsCommitment { } } impl BlockDigest { + pub fn as_array(&self) -> &[u8; BLOCK_DIGEST_SIZE] { + &self.0 + } + pub fn new_without_transactions( authority: AuthorityIndex, round: RoundNumber, block_references: &[BlockReference], acknowledgment_references: &[BlockReference], meta_creation_time_ns: TimestampNs, - signature: &SignatureBytes, merkle_root: Option, strong_vote: Option, ) -> Self { @@ -191,26 +214,125 @@ impl BlockDigest { block_references, acknowledgment_references, meta_creation_time_ns, - signature, merkle_root, strong_vote, None, ) } + /// Canonical Starfish-RBC header-content digest. + /// + /// This encoding is intentionally separate from the legacy block digest: + /// every field is tagged, collection lengths are explicit, integers are + /// big-endian, and the transaction commitment is mandatory. Authentication + /// and protocol sidecars are not part of the content identity. + pub(crate) fn new_starfish_rbc_header( + authority: AuthorityIndex, + round: RoundNumber, + block_references: &[BlockReference], + acknowledgment_references: &[BlockReference], + meta_creation_time_ns: TimestampNs, + transactions_commitment: TransactionsCommitment, + ) -> Self { + const AUTHORITY_FIELD: u8 = 0x01; + const ROUND_FIELD: u8 = 0x02; + const PARENTS_FIELD: u8 = 0x03; + const ACKNOWLEDGMENTS_FIELD: u8 = 0x04; + const CREATION_TIME_FIELD: u8 = 0x05; + const TRANSACTIONS_COMMITMENT_FIELD: u8 = 0x06; + + fn hash_reference(hasher: &mut Blake3Hasher, block_ref: &BlockReference) { + hasher.update(&block_ref.authority.to_be_bytes()); + hasher.update(&block_ref.round.to_be_bytes()); + hasher.update(block_ref.digest.as_ref()); + } + + fn hash_references(hasher: &mut Blake3Hasher, references: &[BlockReference]) { + let length = + u32::try_from(references.len()).expect("Starfish-RBC reference count exceeds u32"); + hasher.update(&length.to_be_bytes()); + for block_ref in references { + hash_reference(hasher, block_ref); + } + } + + let mut hasher = Blake3Hasher::new(); + hasher.update(&[AUTHORITY_FIELD]); + hasher.update(&authority.to_be_bytes()); + hasher.update(&[ROUND_FIELD]); + hasher.update(&round.to_be_bytes()); + hasher.update(&[PARENTS_FIELD]); + hash_references(&mut hasher, block_references); + hasher.update(&[ACKNOWLEDGMENTS_FIELD]); + hash_references(&mut hasher, acknowledgment_references); + hasher.update(&[CREATION_TIME_FIELD]); + hasher.update(&meta_creation_time_ns.to_be_bytes()); + hasher.update(&[TRANSACTIONS_COMMITMENT_FIELD]); + hasher.update(transactions_commitment.as_ref()); + Self(hasher.finalize().into()) + } + + /// Canonical identity for a single-DAG Starfish-RBC V3 block. + /// + /// The existing Starfish fields and the typed RBC references share one + /// digest. Authentication therefore binds evidence to the ordinary block + /// author without introducing a carrier or projected-vertex identity. + pub(crate) fn new_starfish_rbc_single_dag_header( + authority: AuthorityIndex, + round: RoundNumber, + block_references: &[BlockReference], + acknowledgment_references: &[BlockReference], + meta_creation_time_ns: TimestampNs, + transactions_commitment: TransactionsCommitment, + rbc: &StarfishRbcFieldsV3, + ) -> Self { + const DOMAIN: &[u8] = b"STARFISH_RBC_SINGLE_DAG_V3"; + + fn hash_reference(hasher: &mut Blake3Hasher, block_ref: &BlockReference) { + hasher.update(&block_ref.authority.to_be_bytes()); + hasher.update(&block_ref.round.to_be_bytes()); + hasher.update(block_ref.digest.as_ref()); + } + + fn hash_references(hasher: &mut Blake3Hasher, references: &[BlockReference]) { + let length = + u32::try_from(references.len()).expect("Starfish-RBC reference count exceeds u32"); + hasher.update(&length.to_be_bytes()); + for reference in references { + hash_reference(hasher, reference); + } + } + + let mut hasher = Blake3Hasher::new(); + hasher.update(DOMAIN); + hasher.update(&authority.to_be_bytes()); + hasher.update(&round.to_be_bytes()); + hash_references(&mut hasher, block_references); + hash_references(&mut hasher, acknowledgment_references); + hasher.update(&meta_creation_time_ns.to_be_bytes()); + hasher.update(transactions_commitment.as_ref()); + let evidence_len = + u32::try_from(rbc.references().len()).expect("Starfish-RBC evidence count exceeds u32"); + hasher.update(&evidence_len.to_be_bytes()); + for evidence in rbc.references() { + hasher.update(&[evidence.kind().tag()]); + hash_reference(&mut hasher, &evidence.reference()); + } + Self(hasher.finalize().into()) + } + pub fn new_without_transactions_with_unprovable( authority: AuthorityIndex, round: RoundNumber, block_references: &[BlockReference], acknowledgment_references: &[BlockReference], meta_creation_time_ns: TimestampNs, - signature: &SignatureBytes, merkle_root: Option, strong_vote: Option, unprovable_certificate: Option<&(BlockReference, bool)>, ) -> Self { let mut hasher = Blake3Hasher::new(); - Self::digest_without_signature( + Self::digest_contents( &mut hasher, authority, round, @@ -221,7 +343,6 @@ impl BlockDigest { strong_vote, ); Self::hash_unprovable_certificate(&mut hasher, unprovable_certificate); - hasher.update(signature.as_bytes()); Self(hasher.finalize().into()) } @@ -231,7 +352,6 @@ impl BlockDigest { block_references: &[BlockReference], acknowledgment_references: &[BlockReference], meta_creation_time_ns: TimestampNs, - signature: &SignatureBytes, transactions_commitment: Option, strong_vote: Option, ) -> Self { @@ -241,7 +361,6 @@ impl BlockDigest { block_references, acknowledgment_references, meta_creation_time_ns, - signature, transactions_commitment, strong_vote, None, @@ -254,13 +373,12 @@ impl BlockDigest { block_references: &[BlockReference], acknowledgment_references: &[BlockReference], meta_creation_time_ns: TimestampNs, - signature: &SignatureBytes, transactions_commitment: Option, strong_vote: Option, unprovable_certificate: Option<&(BlockReference, bool)>, ) -> Self { let mut hasher = Blake3Hasher::new(); - Self::digest_without_signature( + Self::digest_contents( &mut hasher, authority, round, @@ -271,11 +389,10 @@ impl BlockDigest { strong_vote, ); Self::hash_unprovable_certificate(&mut hasher, unprovable_certificate); - hasher.update(signature.as_bytes()); Self(hasher.finalize().into()) } - pub(crate) fn digest_without_signature( + pub(crate) fn digest_contents( hasher: &mut Blake3Hasher, authority: AuthorityIndex, round: RoundNumber, @@ -305,7 +422,7 @@ impl BlockDigest { /// Extend a block digest hasher with the generalized unprovable /// certificate reference + strong/standard flavor flag. Called after - /// `digest_without_signature` and before finalizing. No-op when `None`, + /// `digest_contents` and before finalizing. No-op when `None`, /// preserving backward compatibility. pub(crate) fn hash_unprovable_certificate( hasher: &mut Blake3Hasher, @@ -473,33 +590,318 @@ fn deserialize_fixed_bytes<'de, D: Deserializer<'de>, const N: usize>( } } -impl PublicKey { - pub fn verify_signature_in_block( +impl MacKey { + pub fn compute_tag( &self, - header: &BlockHeader, - transactions_commitment: Option, - ) -> Result<(), ed25519_consensus::Error> { - let signature = Signature::from(header.signature().0); - let acknowledgments = header.acknowledgments(); - let mut hasher = Blake3Hasher::new(); - BlockDigest::digest_without_signature( - &mut hasher, - header.authority(), - header.round(), - header.block_references(), - &acknowledgments, - header.meta_creation_time_ns(), - transactions_commitment, - header.strong_vote(), - ); - BlockDigest::hash_unprovable_certificate( - &mut hasher, - header.unprovable_certificate.as_ref(), - ); - let digest: [u8; BLOCK_DIGEST_SIZE] = hasher.finalize().into(); - self.0.verify(&signature, digest.as_ref()) + author: AuthorityIndex, + recipient: AuthorityIndex, + content_digest: &BlockDigest, + ) -> MacTag { + let mut hasher = Blake3Hasher::new_keyed(&self.0); + hasher.update(&author.to_be_bytes()); + hasher.update(&recipient.to_be_bytes()); + hasher.update(content_digest.as_ref()); + MacTag(hasher.finalize().into()) + } + + /// Authenticate an already-canonical Starfish-RBC statement. + /// + /// Statement construction and domain separation live in the RBC module; + /// keeping keyed-hasher access here avoids exposing the secret key bytes. + pub(crate) fn compute_rbc_tag(&self, statement: &[u8]) -> MacTag { + let mut hasher = Blake3Hasher::new_keyed(&self.0); + hasher.update(statement); + MacTag(hasher.finalize().into()) + } +} + +/// Generate deterministic, symmetric pairwise keyrings for local benchmarks +/// and tests. Entry `keyrings[a][b]` equals `keyrings[b][a]`. +#[allow(clippy::needless_range_loop)] +pub fn mac_keyrings_for_test(n: usize) -> Vec> { + let mut rng = StdRng::seed_from_u64(0x5354_4152_4649_5348); + let mut keyrings = vec![vec![MacKey([0; MAC_KEY_SIZE]); n]; n]; + for author in 0..n { + for recipient in author..n { + let mut bytes = [0; MAC_KEY_SIZE]; + rng.fill_bytes(&mut bytes); + let key = MacKey(bytes); + keyrings[author][recipient] = key.clone(); + keyrings[recipient][author] = key; + } + } + keyrings +} + +impl Drop for MacKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +impl fmt::Debug for MacKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("MacKey(REDACTED)") + } +} + +impl Serialize for MacKey { + fn serialize(&self, serializer: S) -> Result { + serialize_fixed_bytes(&self.0, serializer) + } +} + +impl<'de> Deserialize<'de> for MacKey { + fn deserialize>(deserializer: D) -> Result { + deserialize_fixed_bytes::(deserializer, "MAC key").map(Self) + } +} + +impl AsBytes for MacTag { + fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +impl MacTag { + pub(crate) fn from_bytes(bytes: [u8; MAC_TAG_SIZE]) -> Self { + Self(bytes) + } +} + +impl PartialEq for MacTag { + fn eq(&self, other: &Self) -> bool { + blake3::Hash::from_bytes(self.0) == blake3::Hash::from_bytes(other.0) + } +} + +impl Eq for MacTag {} + +impl std::hash::Hash for MacTag { + fn hash(&self, state: &mut H) { + std::hash::Hash::hash(&self.0, state); + } +} + +impl AsRef<[u8]> for MacTag { + fn as_ref(&self) -> &[u8] { + &self.0 } +} + +impl fmt::Debug for MacTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Mac({})", hex::encode(&self.0[..4])) + } +} +impl Serialize for MacTag { + fn serialize(&self, serializer: S) -> Result { + serialize_fixed_bytes(&self.0, serializer) + } +} + +impl<'de> Deserialize<'de> for MacTag { + fn deserialize>(deserializer: D) -> Result { + deserialize_fixed_bytes::(deserializer, "MAC tag").map(Self) + } +} + +macro_rules! define_ml_dsa_variant { + ( + parameter_set = $parameter_set:ty, + signature = $signature:ident, + public_key = $public_key:ident, + signer = $signer:ident, + seed_size = $seed_size:ident, + public_key_size = $public_key_size:ident, + signature_size = $signature_size:ident, + test_rng_seed = $test_rng_seed:expr, + label = $label:literal, + dummy_signer = $dummy_signer:ident, + dummy_public_key = $dummy_public_key:ident + ) => { + #[derive(Clone, Eq, PartialEq)] + pub struct $signature(Box<[u8; $signature_size]>); + + #[derive(Clone)] + pub struct $public_key(MlDsaVerifyingKey<$parameter_set>); + + /// Boxed so moving this wrapper does not copy private key material. + #[derive(Clone)] + pub struct $signer(Box>); + + impl $signature { + pub fn from_bytes(bytes: [u8; $signature_size]) -> Self { + Self(Box::new(bytes)) + } + } + + impl AsRef<[u8]> for $signature { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } + } + + impl fmt::Debug for $signature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}Sig({})", $label, &hex::encode(&self.0[..4])) + } + } + + impl Serialize for $signature { + fn serialize(&self, serializer: S) -> Result { + serialize_fixed_bytes(self.0.as_ref(), serializer) + } + } + + impl<'de> Deserialize<'de> for $signature { + fn deserialize>(deserializer: D) -> Result { + deserialize_fixed_bytes::( + deserializer, + concat!($label, " signature"), + ) + .map(Self::from_bytes) + } + } + + impl $public_key { + pub fn from_bytes(bytes: &[u8; $public_key_size]) -> Self { + let encoded = ml_dsa::EncodedVerifyingKey::<$parameter_set>::from(*bytes); + Self(MlDsaVerifyingKey::decode(&encoded)) + } + + pub fn to_bytes(&self) -> [u8; $public_key_size] { + self.0.encode().into() + } + + pub fn verify_digest_signature( + &self, + digest: &BlockDigest, + signature: &$signature, + ) -> Result<(), ml_dsa::signature::Error> { + let signature = MlDsaSignature::<$parameter_set>::try_from(signature.as_ref())?; + self.0.verify(digest.as_ref(), &signature) + } + } + + impl PartialEq for $public_key { + fn eq(&self, other: &Self) -> bool { + self.to_bytes() == other.to_bytes() + } + } + + impl Eq for $public_key {} + + impl fmt::Debug for $public_key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}Pk({})", $label, &hex::encode(&self.to_bytes()[..4])) + } + } + + impl Serialize for $public_key { + fn serialize(&self, serializer: S) -> Result { + serialize_fixed_bytes(&self.to_bytes(), serializer) + } + } + + impl<'de> Deserialize<'de> for $public_key { + fn deserialize>(deserializer: D) -> Result { + let bytes = deserialize_fixed_bytes::( + deserializer, + concat!($label, " public key"), + )?; + Ok(Self::from_bytes(&bytes)) + } + } + + impl $signer { + pub fn new_for_test(n: usize) -> Vec { + let mut rng = StdRng::seed_from_u64($test_rng_seed); + (0..n) + .map(|_| { + let mut bytes = [0; $seed_size]; + rng.fill_bytes(&mut bytes); + let seed = ml_dsa::Seed::from(bytes); + Self(Box::new(MlDsaSigningKey::from_seed(&seed))) + }) + .collect() + } + + pub fn sign_digest(&self, digest: &BlockDigest) -> $signature { + let signature: MlDsaSignature<$parameter_set> = self.0.sign(digest.as_ref()); + $signature::from_bytes(signature.encode().into()) + } + + pub fn public_key(&self) -> $public_key { + $public_key(self.0.verifying_key()) + } + } + + impl fmt::Debug for $signer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}Signer(public_key={:?})", $label, self.public_key()) + } + } + + impl Serialize for $signer { + fn serialize(&self, serializer: S) -> Result { + let seed: [u8; $seed_size] = self.0.to_seed().into(); + serialize_fixed_bytes(&seed, serializer) + } + } + + impl<'de> Deserialize<'de> for $signer { + fn deserialize>(deserializer: D) -> Result { + let bytes = deserialize_fixed_bytes::( + deserializer, + concat!($label, " seed"), + )?; + let seed = ml_dsa::Seed::from(bytes); + Ok(Self(Box::new(MlDsaSigningKey::from_seed(&seed)))) + } + } + + pub fn $dummy_signer() -> $signer { + let seed = ml_dsa::Seed::from([0; $seed_size]); + $signer(Box::new(MlDsaSigningKey::from_seed(&seed))) + } + + pub fn $dummy_public_key() -> $public_key { + $dummy_signer().public_key() + } + }; +} + +define_ml_dsa_variant!( + parameter_set = MlDsa44, + signature = MlDsa44SignatureBytes, + public_key = MlDsa44PublicKey, + signer = MlDsa44Signer, + seed_size = ML_DSA_44_SEED_SIZE, + public_key_size = ML_DSA_44_PUBLIC_KEY_SIZE, + signature_size = ML_DSA_44_SIGNATURE_SIZE, + test_rng_seed = 0x4d4c_4453_4134_3400, + label = "ML-DSA-44", + dummy_signer = dummy_ml_dsa_44_signer, + dummy_public_key = dummy_ml_dsa_44_public_key +); + +define_ml_dsa_variant!( + parameter_set = MlDsa65, + signature = MlDsa65SignatureBytes, + public_key = MlDsa65PublicKey, + signer = MlDsa65Signer, + seed_size = ML_DSA_65_SEED_SIZE, + public_key_size = ML_DSA_65_PUBLIC_KEY_SIZE, + signature_size = ML_DSA_65_SIGNATURE_SIZE, + test_rng_seed = 0x4d4c_4453_4136_3500, + label = "ML-DSA-65", + dummy_signer = dummy_ml_dsa_65_signer, + dummy_public_key = dummy_ml_dsa_65_public_key +); + +impl PublicKey { pub fn to_bytes(&self) -> [u8; 32] { let mut bytes = [0u8; 32]; bytes.copy_from_slice(self.0.as_ref()); @@ -530,56 +932,6 @@ impl Signer { .collect() } - pub fn sign_block( - &self, - authority: AuthorityIndex, - round: RoundNumber, - block_references: &[BlockReference], - acknowledgment_references: &[BlockReference], - meta_creation_time_ns: TimestampNs, - transactions_commitment: Option, - strong_vote: Option, - ) -> SignatureBytes { - self.sign_block_with_unprovable( - authority, - round, - block_references, - acknowledgment_references, - meta_creation_time_ns, - transactions_commitment, - strong_vote, - None, - ) - } - - pub fn sign_block_with_unprovable( - &self, - authority: AuthorityIndex, - round: RoundNumber, - block_references: &[BlockReference], - acknowledgment_references: &[BlockReference], - meta_creation_time_ns: TimestampNs, - transactions_commitment: Option, - strong_vote: Option, - unprovable_certificate: Option<&(BlockReference, bool)>, - ) -> SignatureBytes { - let mut hasher = Blake3Hasher::new(); - BlockDigest::digest_without_signature( - &mut hasher, - authority, - round, - block_references, - acknowledgment_references, - meta_creation_time_ns, - transactions_commitment, - strong_vote, - ); - BlockDigest::hash_unprovable_certificate(&mut hasher, unprovable_certificate); - let digest: [u8; BLOCK_DIGEST_SIZE] = hasher.finalize().into(); - let signature = self.0.sign(digest.as_ref()); - SignatureBytes(signature.to_bytes()) - } - /// Sign a pre-computed 32-byte digest. Used for Sailfish++ control /// messages (timeout, no-vote) that don't fit the block-signing schema. pub fn sign_digest(&self, digest: &[u8; 32]) -> SignatureBytes { @@ -1052,6 +1404,71 @@ mod tests { use super::*; use serde::{Deserialize, Serialize}; + #[test] + fn mac_keyrings_are_symmetric_and_bind_recipient() { + let keyrings = mac_keyrings_for_test(4); + let digest = BlockDigest([7; BLOCK_DIGEST_SIZE]); + let tag = keyrings[1][3].compute_tag(1, 3, &digest); + + assert_eq!(tag, keyrings[3][1].compute_tag(1, 3, &digest)); + assert_ne!(tag, keyrings[3][1].compute_tag(1, 2, &digest)); + assert_ne!(tag, keyrings[3][1].compute_tag(2, 3, &digest)); + } + + #[test] + fn ml_dsa_44_sign_verify_and_serde_roundtrip() { + let signer = MlDsa44Signer::new_for_test(1).pop().unwrap(); + let public_key = signer.public_key(); + let digest = BlockDigest([9; BLOCK_DIGEST_SIZE]); + let signature = signer.sign_digest(&digest); + + assert!( + public_key + .verify_digest_signature(&digest, &signature) + .is_ok() + ); + assert!( + public_key + .verify_digest_signature(&BlockDigest([8; BLOCK_DIGEST_SIZE]), &signature) + .is_err() + ); + + let encoded_key = bincode::serialize(&public_key).unwrap(); + let decoded_key: MlDsa44PublicKey = bincode::deserialize(&encoded_key).unwrap(); + let encoded_signature = bincode::serialize(&signature).unwrap(); + let decoded_signature: MlDsa44SignatureBytes = + bincode::deserialize(&encoded_signature).unwrap(); + assert_eq!(public_key, decoded_key); + assert_eq!(signature, decoded_signature); + } + + #[test] + fn ml_dsa_65_sign_verify_and_serde_roundtrip() { + let signer = MlDsa65Signer::new_for_test(1).pop().unwrap(); + let public_key = signer.public_key(); + let digest = BlockDigest([9; BLOCK_DIGEST_SIZE]); + let signature = signer.sign_digest(&digest); + + assert!( + public_key + .verify_digest_signature(&digest, &signature) + .is_ok() + ); + assert!( + public_key + .verify_digest_signature(&BlockDigest([8; BLOCK_DIGEST_SIZE]), &signature) + .is_err() + ); + + let encoded_key = bincode::serialize(&public_key).unwrap(); + let decoded_key: MlDsa65PublicKey = bincode::deserialize(&encoded_key).unwrap(); + let encoded_signature = bincode::serialize(&signature).unwrap(); + let decoded_signature: MlDsa65SignatureBytes = + bincode::deserialize(&encoded_signature).unwrap(); + assert_eq!(public_key, decoded_key); + assert_eq!(signature, decoded_signature); + } + #[test] fn bls_sign_verify_roundtrip() { let signers = BlsSigner::new_for_test(3); @@ -1111,6 +1528,14 @@ mod tests { bls_signer: BlsSigner, bls_public_key: BlsPublicKey, bls_signature: BlsSignatureBytes, + mac_key: MacKey, + mac_tag: MacTag, + ml_dsa_44_signer: MlDsa44Signer, + ml_dsa_44_public_key: MlDsa44PublicKey, + ml_dsa_44_signature: MlDsa44SignatureBytes, + ml_dsa_65_signer: MlDsa65Signer, + ml_dsa_65_public_key: MlDsa65PublicKey, + ml_dsa_65_signature: MlDsa65SignatureBytes, } #[test] @@ -1118,15 +1543,28 @@ mod tests { let signer = Signer::new_for_test(1).pop().unwrap(); let public_key = signer.public_key(); let bls_signer = dummy_bls_signer(); + let mac_key = MacKey([10; MAC_KEY_SIZE]); + let block_digest = BlockDigest([7u8; BLOCK_DIGEST_SIZE]); + let mac_tag = mac_key.compute_tag(0, 1, &block_digest); + let ml_dsa_44_signer = dummy_ml_dsa_44_signer(); + let ml_dsa_65_signer = dummy_ml_dsa_65_signer(); let fixture = CryptoYamlFixture { signer, public_key, - block_digest: BlockDigest([7u8; BLOCK_DIGEST_SIZE]), + block_digest, transactions_commitment: TransactionsCommitment([8u8; TRANSACTIONS_DIGEST_SIZE]), signature: SignatureBytes([9u8; SIGNATURE_SIZE]), bls_public_key: bls_signer.public_key(), bls_signature: bls_signer.sign_digest(&[5u8; 32]), bls_signer, + mac_key, + mac_tag, + ml_dsa_44_public_key: ml_dsa_44_signer.public_key(), + ml_dsa_44_signature: ml_dsa_44_signer.sign_digest(&block_digest), + ml_dsa_44_signer, + ml_dsa_65_public_key: ml_dsa_65_signer.public_key(), + ml_dsa_65_signature: ml_dsa_65_signer.sign_digest(&block_digest), + ml_dsa_65_signer, }; let yaml = serde_yaml::to_string(&fixture).unwrap(); @@ -1142,6 +1580,20 @@ mod tests { assert!(fixture.signature == decoded.signature); assert_eq!(fixture.bls_public_key, decoded.bls_public_key); assert_eq!(fixture.bls_signature, decoded.bls_signature); + assert_eq!(fixture.mac_key, decoded.mac_key); + assert_eq!(fixture.mac_tag, decoded.mac_tag); + assert_eq!(fixture.ml_dsa_44_public_key, decoded.ml_dsa_44_public_key); + assert_eq!(fixture.ml_dsa_44_signature, decoded.ml_dsa_44_signature); + assert_eq!( + fixture.ml_dsa_44_signer.public_key(), + decoded.ml_dsa_44_signer.public_key() + ); + assert_eq!(fixture.ml_dsa_65_public_key, decoded.ml_dsa_65_public_key); + assert_eq!(fixture.ml_dsa_65_signature, decoded.ml_dsa_65_signature); + assert_eq!( + fixture.ml_dsa_65_signer.public_key(), + decoded.ml_dsa_65_signer.public_key() + ); assert_eq!( fixture.bls_signer.public_key(), decoded.bls_signer.public_key() diff --git a/crates/starfish-core/src/dag_state.rs b/crates/starfish-core/src/dag_state.rs index 8f41a3a0..59bbabd6 100644 --- a/crates/starfish-core/src/dag_state.rs +++ b/crates/starfish-core/src/dag_state.rs @@ -28,13 +28,14 @@ use crate::{ metrics::{Metrics, UtilizationTimerExt}, network::ShardPayload, rocks_store::RocksStore, + starfish_rbc::PinnedRbcHeader, state::{RecoveredState, RecoveredStateBuilder}, store::Store, threshold_clock::ThresholdClockAggregator, types::{ - AuthorityIndex, AuthoritySet, BlockDigest, BlockReference, BlsAggregateCertificate, - ProvableShard, RoundNumber, SailfishNoVoteCert, SailfishTimeoutCert, TransactionData, - VerifiedBlock, + AuthorityIndex, AuthoritySet, BlockAuthentication, BlockAuthenticationScheme, BlockDigest, + BlockReference, BlsAggregateCertificate, ProvableShard, RoundNumber, SailfishNoVoteCert, + SailfishTimeoutCert, TransactionData, VerifiedBlock, }, }; @@ -81,6 +82,8 @@ pub enum DataSource { /// Response to RoundGapRequest (blocks the requester was missing at a /// round). RoundGapResponse, + /// Transaction data co-carried by the direct Starfish-RBC INIT. + StarfishRbcPayload, } impl DataSource { @@ -95,6 +98,7 @@ impl DataSource { Self::Recover => "recover", Self::UnprovableCertificateResponse => "unprovable_certificate_response", Self::RoundGapResponse => "round_gap_response", + Self::StarfishRbcPayload => "starfish_rbc_payload", } } } @@ -113,11 +117,17 @@ pub enum DacCertificateVerificationState { Rejected, } -#[derive(Clone, Debug, Copy, PartialEq)] +#[derive(Clone, Debug, Copy, Eq, PartialEq)] pub enum ConsensusProtocol { Mysticeti, CordialMiners, Starfish, + /// Plain Starfish ordering over headers certified by the Starfish-RBC + /// reliable-broadcast service. + StarfishRbc, + /// One-DAG Starfish-RBC: ordinary Starfish blocks carry typed RBC + /// references and retain the same identity from admission through commit. + StarfishRbcSingleDag, StarfishSpeed, StarfishBls, SailfishPlusPlus, @@ -167,19 +177,27 @@ pub enum ConsensusProtocol { impl ConsensusProtocol { pub fn from_str(s: &str) -> Self { + ProtocolConfig::from_str(s) + .unwrap_or_else(|error| panic!("{error}")) + .consensus_protocol + } + + fn from_known_str(s: &str) -> Option { match s { - "mysticeti" => ConsensusProtocol::Mysticeti, - "cordial-miners" => ConsensusProtocol::CordialMiners, - "starfish" => ConsensusProtocol::Starfish, - "starfish-bls" | "starfish-l" => ConsensusProtocol::StarfishBls, - "starfish-speed" | "starfish-s" => ConsensusProtocol::StarfishSpeed, - "sailfish++" | "sailfish-pp" => ConsensusProtocol::SailfishPlusPlus, - "bluestreak" => ConsensusProtocol::Bluestreak, - "mysticeti-bls" | "mysticeti-l" => ConsensusProtocol::MysticetiBls, + "mysticeti" => Some(ConsensusProtocol::Mysticeti), + "cordial-miners" => Some(ConsensusProtocol::CordialMiners), + "starfish" => Some(ConsensusProtocol::Starfish), + "starfish-rbc" => Some(ConsensusProtocol::StarfishRbc), + "starfish-rbc-single-dag" => Some(ConsensusProtocol::StarfishRbcSingleDag), + "starfish-bls" | "starfish-l" => Some(ConsensusProtocol::StarfishBls), + "starfish-speed" | "starfish-s" => Some(ConsensusProtocol::StarfishSpeed), + "sailfish++" | "sailfish-pp" => Some(ConsensusProtocol::SailfishPlusPlus), + "bluestreak" => Some(ConsensusProtocol::Bluestreak), + "mysticeti-bls" | "mysticeti-l" => Some(ConsensusProtocol::MysticetiBls), "sparse-starfish-speed" | "sparse-starfish" | "ssfs" => { - ConsensusProtocol::SparseStarfishSpeed + Some(ConsensusProtocol::SparseStarfishSpeed) } - _ => ConsensusProtocol::Starfish, + _ => None, } } @@ -187,6 +205,8 @@ impl ConsensusProtocol { matches!( self, ConsensusProtocol::Starfish + | ConsensusProtocol::StarfishRbc + | ConsensusProtocol::StarfishRbcSingleDag | ConsensusProtocol::StarfishBls | ConsensusProtocol::StarfishSpeed | ConsensusProtocol::SparseStarfishSpeed @@ -197,6 +217,17 @@ impl ConsensusProtocol { matches!(self, ConsensusProtocol::SailfishPlusPlus) } + pub fn is_starfish_rbc(self) -> bool { + matches!( + self, + ConsensusProtocol::StarfishRbc | ConsensusProtocol::StarfishRbcSingleDag + ) + } + + pub fn is_starfish_rbc_single_dag(self) -> bool { + matches!(self, ConsensusProtocol::StarfishRbcSingleDag) + } + pub fn is_bluestreak(self) -> bool { matches!(self, ConsensusProtocol::Bluestreak) } @@ -237,6 +268,8 @@ impl ConsensusProtocol { matches!( self, ConsensusProtocol::SailfishPlusPlus + | ConsensusProtocol::StarfishRbc + | ConsensusProtocol::StarfishRbcSingleDag | ConsensusProtocol::Bluestreak | ConsensusProtocol::StarfishBls | ConsensusProtocol::MysticetiBls @@ -262,6 +295,8 @@ impl ConsensusProtocol { | ConsensusProtocol::StarfishBls => DisseminationMode::Pull, ConsensusProtocol::CordialMiners => DisseminationMode::PushCausal, ConsensusProtocol::Starfish + | ConsensusProtocol::StarfishRbc + | ConsensusProtocol::StarfishRbcSingleDag | ConsensusProtocol::StarfishSpeed | ConsensusProtocol::SparseStarfishSpeed => DisseminationMode::PushUseful, } @@ -289,6 +324,78 @@ impl ConsensusProtocol { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProtocolConfig { + pub consensus_protocol: ConsensusProtocol, + pub block_authentication_scheme: BlockAuthenticationScheme, +} + +impl ProtocolConfig { + pub fn from_str(value: &str) -> Result { + Self::from_selection(value, None) + } + + pub fn from_selection( + consensus: &str, + block_authentication: Option<&str>, + ) -> Result { + let (protocol_name, is_mac_experiment) = consensus + .strip_suffix("-mac") + .map(|base| (base, true)) + .unwrap_or((consensus, false)); + let consensus_protocol = ConsensusProtocol::from_known_str(protocol_name) + .ok_or_else(|| format!("Unknown consensus protocol '{consensus}'"))?; + + let block_authentication_scheme = if is_mac_experiment { + if block_authentication.is_some() { + return Err(format!( + "'{consensus}' is an experimental MAC protocol and cannot be combined with \ + --block-authentication" + )); + } + if !matches!( + consensus_protocol, + ConsensusProtocol::Starfish + | ConsensusProtocol::StarfishSpeed + | ConsensusProtocol::SparseStarfishSpeed + | ConsensusProtocol::Bluestreak + ) { + return Err(format!( + "The experimental MAC protocol is not available for '{protocol_name}'" + )); + } + BlockAuthenticationScheme::MacVector + } else { + match block_authentication.unwrap_or("ed25519") { + "ed25519" => BlockAuthenticationScheme::Ed25519, + "ml-dsa-44" => BlockAuthenticationScheme::MlDsa44, + "ml-dsa-65" => BlockAuthenticationScheme::MlDsa65, + "mac" if consensus_protocol.is_starfish_rbc() => { + BlockAuthenticationScheme::MacVector + } + "mac" => { + return Err( + "MAC initial authentication is only available for 'starfish-rbc'; use an \ + experimental '*-mac' protocol for the legacy lower-bound benchmark" + .to_string(), + ); + } + value => { + return Err(format!( + "Unknown block authentication scheme '{value}'. Use 'ed25519', \ + 'ml-dsa-44', or 'ml-dsa-65' (and 'mac' for 'starfish-rbc')." + )); + } + } + }; + + Ok(Self { + consensus_protocol, + block_authentication_scheme, + }) + } +} + const STARFISH_SPEED_HINT_WINDOW_LEADER_ROUNDS: usize = 10; #[allow(unused)] @@ -338,6 +445,7 @@ pub struct DagState { store: Arc, metrics: Arc, pub(crate) consensus_protocol: ConsensusProtocol, + pub(crate) block_authentication_scheme: BlockAuthenticationScheme, pub(crate) committee_size: usize, pub(crate) byzantine_strategy: Option, committee: Arc, @@ -458,6 +566,14 @@ struct DagStateInner { /// A vertex joins the clean DAG only once its direct parents are also /// clean (or genesis), making the usable clean DAG ancestor-closed. clean_vertices: Vec>, + /// Starfish-RBC headers that this validator has locally delivered. + /// + /// Delivery and dirty-DAG insertion are independent asynchronous events: + /// a delivered header may still be waiting in `BlockManager` for a + /// missing parent. Retaining this exact-reference latch makes either + /// event order safe. Version one deliberately keeps these latches for the + /// process lifetime because no RBC retirement watermark has been proved. + rbc_delivered_vertices: Vec>, /// Per-round support (by stake) for clean vertices. Tracks which /// authorities have at least one clean vertex at a given round so we can /// answer clean-quorum checks without scanning all authorities. @@ -519,6 +635,32 @@ impl DagState { storage_backend: &StorageBackend, strong_vote_adaptive_acknowledgments: bool, dissemination_mode: DisseminationMode, + ) -> RecoveredState { + let protocol_config = ProtocolConfig::from_str(&consensus).expect("validated protocol"); + Self::open_with_protocol_config( + authority, + path, + metrics, + committee, + byzantine_strategy, + protocol_config, + storage_backend, + strong_vote_adaptive_acknowledgments, + dissemination_mode, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn open_with_protocol_config( + authority: AuthorityIndex, + path: impl AsRef, + metrics: Arc, + committee: Arc, + byzantine_strategy: String, + protocol_config: ProtocolConfig, + storage_backend: &StorageBackend, + strong_vote_adaptive_acknowledgments: bool, + dissemination_mode: DisseminationMode, ) -> RecoveredState { assert!( committee.len() <= crate::types::MAX_COMMITTEE_SIZE as usize, @@ -542,7 +684,7 @@ impl DagState { Arc::new(RocksStore::open(&path).expect("Failed to open RocksDB")) } }; - let consensus_protocol = ConsensusProtocol::from_str(&consensus); + let consensus_protocol = protocol_config.consensus_protocol; let resolved_dissemination = consensus_protocol.resolve_dissemination_mode(dissemination_mode); let push_mode = matches!( @@ -588,6 +730,7 @@ impl DagState { precomputed_round_sigs: BTreeMap::new(), precomputed_leader_sigs: BTreeMap::new(), clean_vertices: (0..n).map(|_| BTreeSet::new()).collect(), + rbc_delivered_vertices: (0..n).map(|_| BTreeSet::new()).collect(), clean_round_support: BTreeMap::new(), clean_quorum_round: 0, pending_clean_vertices: (0..n).map(|_| BTreeSet::new()).collect(), @@ -805,6 +948,10 @@ impl DagState { match &consensus_protocol { ConsensusProtocol::Mysticeti => tracing::info!("Starting Mysticeti protocol"), ConsensusProtocol::Starfish => tracing::info!("Starting Starfish protocol"), + ConsensusProtocol::StarfishRbc => tracing::info!("Starting Starfish-RBC protocol"), + ConsensusProtocol::StarfishRbcSingleDag => { + tracing::info!("Starting single-DAG Starfish-RBC protocol") + } ConsensusProtocol::StarfishBls => tracing::info!("Starting Starfish-BLS protocol"), ConsensusProtocol::StarfishSpeed => tracing::info!("Starting Starfish-Speed protocol"), ConsensusProtocol::CordialMiners => tracing::info!("Starting Cordial Miners protocol"), @@ -829,6 +976,7 @@ impl DagState { dag_state_inner: Arc::new(RwLock::new(inner)), metrics, consensus_protocol, + block_authentication_scheme: protocol_config.block_authentication_scheme, round_block_cache: Arc::new(parking_lot::Mutex::new(AHashMap::new())), genesis, strong_vote_adaptive_acknowledgments, @@ -875,7 +1023,9 @@ impl DagState { /// round `r - 1`. BLS protocols additionally gate on the highest BLS /// round certificate + 1. Other protocols use the raw threshold clock. pub fn proposal_round(&self) -> RoundNumber { - if !self.consensus_protocol.uses_dual_dag() { + if !self.consensus_protocol.uses_dual_dag() + || self.consensus_protocol.is_starfish_rbc_single_dag() + { return self.threshold_clock_round(); } @@ -1138,6 +1288,66 @@ impl DagState { self.dag_state_inner.read().get_storage_block(reference) } + /// Upgrade an already stored recipient-only MAC copy with a later verified + /// full-vector copy. This intentionally updates only the persisted header + /// and the matching in-memory value: the block is not re-added to the DAG, + /// so threshold clocks, votes, consensus notifications, and acceptance + /// metrics are left untouched. + pub(crate) fn upgrade_mac_authentication(&self, incoming: &VerifiedBlock) -> bool { + if !incoming.has_full_mac_vector() { + return false; + } + + let reference = *incoming.reference(); + let Some(existing) = self.get_storage_block(reference) else { + return false; + }; + if !matches!(existing.authentication(), BlockAuthentication::MacTag(_)) { + return false; + } + let Some(mut upgraded) = existing.merge_same_block(incoming) else { + return false; + }; + upgraded.preserialize(); + + let store_start = std::time::Instant::now(); + self.store + .store_header_bytes( + upgraded.reference(), + upgraded + .serialized_header_bytes() + .expect("upgraded header should be preserialized"), + ) + .expect("Failed to store upgraded MAC-vector header"); + self.metrics + .store_block_latency_us + .inc_by(store_start.elapsed().as_micros() as u64); + self.metrics.store_block_count.inc(); + + // Preserve any transaction data that may have arrived concurrently + // with the authentication upgrade. + let mut inner = self.dag_state_inner.write(); + let authority = reference.authority as usize; + let Some(blocks_at_round) = inner.index[authority].get_mut(&reference.round) else { + // The block was evicted; the persistent header update above is the + // authoritative copy and it should remain evicted from memory. + return true; + }; + let Some(current) = blocks_at_round.get_mut(&reference.digest) else { + return true; + }; + if matches!(current.authentication(), BlockAuthentication::MacTag(_)) { + let mut memory_upgrade = current + .merge_same_block(incoming) + .expect("tag-only copy should accept a full-vector upgrade"); + memory_upgrade.preserialize(); + *current = Data::new(memory_upgrade); + *inner.round_version.entry(reference.round).or_insert(0) += 1; + } + + true + } + /// Look up the `transactions_commitment` for a block in the DAG. pub fn get_transactions_commitment( &self, @@ -1260,6 +1470,17 @@ impl DagState { inner.precomputed_leader_sigs.insert(leader_ref, sig); } CertificateEvent::BlockVerified(block_ref) => { + // This event is a capability produced only by the BLS + // verifier. In particular it must never become an + // alternate clean-admission path for Starfish-RBC, whose + // sole production capability is `PinnedRbcHeader`. + if !inner.consensus_protocol.uses_bls() { + tracing::warn!( + "Ignoring BLS block-verification event for non-BLS protocol: {}", + block_ref + ); + continue; + } let auth = block_ref.authority as usize; if inner.get_block(block_ref).is_none() { inner.bls_verified_blocks[auth].insert(block_ref); @@ -1322,6 +1543,10 @@ impl DagState { /// A newly delivered vertex becomes usable only after all of its direct /// parents are also clean (or genesis). pub fn mark_vertices_clean(&self, block_refs: &[BlockReference]) -> bool { + assert!( + !self.consensus_protocol.is_starfish_rbc(), + "Starfish-RBC cleanliness requires a local RBC delivery event" + ); if block_refs.is_empty() { return false; } @@ -1341,6 +1566,42 @@ impl DagState { self.mark_vertices_clean(&[block_ref]) } + /// Apply locally observed Starfish-RBC deliveries. + /// + /// The pin is the service-to-core delivery capability. The delivery latch + /// is recorded even when the header is not dirty-DAG connected yet; + /// `add_block` completes activation after insertion. + pub(crate) fn apply_starfish_rbc_deliveries(&self, delivered: &[PinnedRbcHeader]) -> bool { + assert!( + self.consensus_protocol.is_starfish_rbc(), + "RBC deliveries are only valid for Starfish-RBC" + ); + if delivered.is_empty() { + return false; + } + + let mut inner = self.dag_state_inner.write(); + let mut activated = Vec::new(); + for header in delivered { + inner.record_rbc_delivery(header.reference(), &self.committee, &mut activated); + } + !activated.is_empty() + } + + #[cfg(test)] + pub(crate) fn apply_starfish_rbc_delivery_refs_for_test( + &self, + delivered: &[BlockReference], + ) -> bool { + assert!(self.consensus_protocol.is_starfish_rbc()); + let mut inner = self.dag_state_inner.write(); + let mut activated = Vec::new(); + for &block_ref in delivered { + inner.record_rbc_delivery(block_ref, &self.committee, &mut activated); + } + !activated.is_empty() + } + /// Drain clean dual-DAG vertices that still need to be persisted at /// the next storage flush boundary. pub fn take_pending_clean_refs(&self) -> Vec { @@ -1709,7 +1970,14 @@ impl DagState { ) -> bool { let inner = self.dag_state_inner.read(); let leader_round = quorum_round - 1; - let blocks = inner.get_blocks_by_round(leader_round); + let mut blocks = inner.get_blocks_by_round(leader_round); + if self.consensus_protocol.is_starfish_rbc() + && !self.consensus_protocol.is_starfish_rbc_single_dag() + { + blocks.retain(|block| { + inner.clean_vertices[block.authority() as usize].contains(block.reference()) + }); + } if blocks.is_empty() { return false; } @@ -1791,6 +2059,7 @@ impl DagState { } if self.consensus_protocol.uses_dual_dag() + && !self.consensus_protocol.is_starfish_rbc_single_dag() && quorum_round > 1 && !self.clean_parent_quorum(quorum_round - 1) { @@ -2290,6 +2559,15 @@ impl DagState { pub fn cleanup(&self) { let _timer = self.metrics.dag_state_cleanup_util.utilization_timer(); + // Version-one Starfish-RBC deliberately has no state-retirement + // rule. Phase locks, delivered slots, clean dependencies, and the + // blocks consumed by clean-only consensus must remain available for + // the process lifetime. A bounded GC requires a proved durable + // retirement watermark and is deferred with crash recovery. + if self.consensus_protocol.is_starfish_rbc() { + return; + } + let (highest_round, lowest_round, block_count, max_evicted, evicted_rounds) = { let mut inner = self.dag_state_inner.write(); inner.evict_per_authority(); @@ -2711,6 +2989,24 @@ impl DagStateInner { } } + /// Record local reliable delivery and activate the exact vertex if its + /// dirty-DAG carrier is already present. If insertion is still blocked on + /// a missing parent, `add_block` observes the retained latch later. + fn record_rbc_delivery( + &mut self, + block_ref: BlockReference, + committee: &Committee, + activated: &mut Vec, + ) { + let auth = block_ref.authority as usize; + if !self.rbc_delivered_vertices[auth].insert(block_ref) { + return; + } + if self.get_block(block_ref).is_some() { + self.note_clean_vertex(block_ref, committee, activated); + } + } + /// Register a locally verified dual-DAG vertex. /// If all causal predecessors are already clean, the vertex activates /// immediately; otherwise it waits on the missing clean dependencies. @@ -2727,51 +3023,60 @@ impl DagStateInner { return; } - let missing_parents = self.missing_clean_parents(block_ref); - if missing_parents.is_empty() { + let missing_dependencies = self.missing_clean_dependencies(block_ref); + if missing_dependencies.is_empty() { self.activate_clean_vertex(block_ref, committee, activated); return; } self.pending_clean_vertices[auth].insert(block_ref); self.pending_clean_vertex_counts - .insert(block_ref, missing_parents.len()); - for parent in missing_parents { + .insert(block_ref, missing_dependencies.len()); + for dependency in missing_dependencies { self.pending_clean_vertex_children - .entry(parent) + .entry(dependency) .or_default() .insert(block_ref); } } - /// Return the direct causal dependencies that still block this vertex from - /// entering the clean DAG. - fn missing_clean_parents(&self, block_ref: BlockReference) -> Vec { + /// Return the exact clean dependencies that still block this vertex. + /// Starfish-RBC includes logical acknowledgments because they can affect + /// sequencing. A reference shared by the parent and compressed-ack lists + /// is counted once; otherwise the reverse BTreeSet would wake the child + /// once while its missing count remained above zero. + fn missing_clean_dependencies(&self, block_ref: BlockReference) -> Vec { let block = self .get_storage_block(block_ref) .unwrap_or_else(|| panic!("Clean block {block_ref} should exist in DagState")); - let mut missing = Vec::new(); + let mut dependencies = BTreeSet::new(); for parent in block.block_references() { - if parent.round == 0 { - continue; + if parent.round > 0 { + dependencies.insert(*parent); } - if !self.clean_vertices[parent.authority as usize].contains(parent) { - missing.push(*parent); + } + if self.consensus_protocol.is_starfish_rbc() { + for acknowledgment in block.acknowledgments() { + if acknowledgment.round > 0 { + dependencies.insert(acknowledgment); + } } } // Bluestreak / SparseStarfishSpeed: the unprovable_certificate target // is also a causal dependency that must be ancestor-closed. if self.consensus_protocol.carries_unprovable_certificate() { if let Some((cert_ref, _strong)) = block.unprovable_certificate() { - if cert_ref.round > 0 - && !self.clean_vertices[cert_ref.authority as usize].contains(&cert_ref) - && !missing.contains(&cert_ref) - { - missing.push(cert_ref); + if cert_ref.round > 0 { + dependencies.insert(cert_ref); } } } - missing + dependencies + .into_iter() + .filter(|dependency| { + !self.clean_vertices[dependency.authority as usize].contains(dependency) + }) + .collect() } /// Move a vertex into the clean DAG and recursively wake any children that @@ -2795,11 +3100,25 @@ impl DagStateInner { let block = self .get_storage_block(block_ref) .unwrap_or_else(|| panic!("Clean block {block_ref} should exist in DagState")); - for parent in block.block_references() { - if let Some(children) = self.pending_clean_vertex_children.get_mut(parent) { + let mut dependencies: BTreeSet = block + .block_references() + .iter() + .copied() + .filter(|reference| reference.round > 0) + .collect(); + if self.consensus_protocol.is_starfish_rbc() { + dependencies.extend( + block + .acknowledgments() + .into_iter() + .filter(|reference| reference.round > 0), + ); + } + for dependency in dependencies { + if let Some(children) = self.pending_clean_vertex_children.get_mut(&dependency) { children.remove(&block_ref); if children.is_empty() { - self.pending_clean_vertex_children.remove(parent); + self.pending_clean_vertex_children.remove(&dependency); } } } @@ -2817,6 +3136,14 @@ impl DagStateInner { activated.push(block_ref); self.pending_persisted_clean_vertices[auth].insert(block_ref); + if self.consensus_protocol.is_starfish_rbc() { + // Invalidate the universal committer's cached voter view when an + // already-inserted block becomes consensus-visible. + *self.round_version.entry(block_ref.round).or_insert(0) += 1; + if !block.has_empty_payload() { + self.maybe_queue_ack(block_ref); + } + } let waiting_children = self .pending_clean_vertex_children @@ -2885,7 +3212,12 @@ impl DagStateInner { // For SSFS this also gives `compute_unprovable_certificate` an O(1) // precomputed strong-quorum lookup at block creation. self.check_pre_clean(&block, committee, activated); - if self.bls_verified_blocks[auth].remove(reference) { + if self.consensus_protocol.is_starfish_rbc() + && self.rbc_delivered_vertices[auth].contains(reference) + { + self.note_clean_vertex(*reference, committee, activated); + } + if self.consensus_protocol.uses_bls() && self.bls_verified_blocks[auth].remove(reference) { self.note_clean_vertex(*reference, committee, activated); } } @@ -2900,7 +3232,10 @@ impl DagStateInner { committee: &Committee, activated: &mut Vec, ) { - if !self.consensus_protocol.uses_dual_dag() || block.round() <= 1 { + if !self.consensus_protocol.uses_dual_dag() + || self.consensus_protocol.is_starfish_rbc() + || block.round() <= 1 + { return; } @@ -3166,21 +3501,31 @@ impl DagStateInner { /// Queue an acknowledgment for `block_ref` only when all prerequisites are /// met. For StarfishBls the block must be both data-available and - /// DAC-certified; other protocols only require data availability. + /// DAC-certified. Starfish-RBC additionally requires local clean + /// activation, so dirty headers can never influence acknowledgment-based + /// sequencing. fn maybe_queue_ack(&mut self, block_ref: BlockReference) { - let Some(pending) = self.pending_acknowledgment.as_mut() else { - return; - }; let auth = block_ref.authority as usize; if !self.data_availability[auth].contains(&block_ref) { return; } + if self.consensus_protocol.is_starfish_rbc() + && !self.clean_vertices[auth].contains(&block_ref) + { + return; + } if self.consensus_protocol == ConsensusProtocol::StarfishBls && (block_ref.authority != self.authority || !self.dac_certificates[auth].contains_key(&block_ref)) { return; } + let Some(pending) = self.pending_acknowledgment.as_mut() else { + return; + }; + if self.consensus_protocol.is_starfish_rbc() && pending.contains(&block_ref) { + return; + } pending.push(block_ref); } @@ -3400,21 +3745,23 @@ mod tests { use super::{ ByzantineStrategy, CACHED_ROUNDS, CertificateEvent, ConsensusProtocol, - DacCertificateVerificationState, DagState, DataSource, OwnBlockData, + DacCertificateVerificationState, DagState, DataSource, OwnBlockData, ProtocolConfig, }; use crate::{ committee::Committee, config::{DisseminationMode, StorageBackend}, crypto::{ - BLS_SIGNATURE_SIZE, BlockDigest, BlsSignatureBytes, SignatureBytes, + self, BLS_SIGNATURE_SIZE, BlockDigest, BlsSignatureBytes, SignatureBytes, TransactionsCommitment, }, data::Data, + encoder::ShardEncoder, metrics::Metrics, types::{ - AuthorityIndex, AuthoritySet, BaseTransaction, BlockReference, BlsAggregateCertificate, - ProvableShard, RoundNumber, SailfishFields, SailfishNoVoteCert, Transaction, - VerifiedBlock, + AuthorityIndex, AuthoritySet, BaseTransaction, BlockAuthentication, + BlockAuthenticationScheme, BlockAuthorizer, BlockReference, BlsAggregateCertificate, + Encoder, ProvableShard, RoundNumber, SailfishFields, SailfishNoVoteCert, + StarfishRbcFieldsV3, Transaction, TransactionData, VerifiedBlock, }, }; @@ -3599,11 +3946,50 @@ mod tests { Data::new(block) } + fn make_starfish_rbc_block( + authority: AuthorityIndex, + round: RoundNumber, + parents: Vec, + acknowledgments: Vec, + ) -> Data { + let mut block = VerifiedBlock::new_starfish_rbc( + authority, + round, + parents, + acknowledgments, + round as u64, + Vec::new(), + None, + ); + block.preserialize(); + Data::new(block) + } + + fn make_starfish_rbc_single_dag_block( + authority: AuthorityIndex, + round: RoundNumber, + parents: Vec, + ) -> Data { + let mut block = VerifiedBlock::new_starfish_rbc_single_dag( + authority, + round, + parents, + Vec::new(), + round as u64, + Vec::new(), + None, + StarfishRbcFieldsV3::default(), + ); + block.preserialize(); + Data::new(block) + } + #[test] fn acknowledgments_are_only_enabled_for_starfish_variants() { assert!(!ConsensusProtocol::Mysticeti.supports_acknowledgments()); assert!(!ConsensusProtocol::CordialMiners.supports_acknowledgments()); assert!(ConsensusProtocol::Starfish.supports_acknowledgments()); + assert!(ConsensusProtocol::StarfishRbc.supports_acknowledgments()); assert!(ConsensusProtocol::StarfishSpeed.supports_acknowledgments()); assert!(ConsensusProtocol::StarfishBls.supports_acknowledgments()); assert!(ConsensusProtocol::SparseStarfishSpeed.supports_acknowledgments()); @@ -3671,6 +4057,65 @@ mod tests { ); } + #[test] + fn full_mac_vector_upgrades_tag_only_block_in_memory_and_storage() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = crypto::mac_keyrings_for_test(committee.len()); + let mut full = VerifiedBlock::new_with_authorizer_and_unprovable( + 1, + 1, + committee + .authorities() + .map(|authority| BlockReference::new_test(authority, 0)) + .collect(), + None, + Vec::new(), + 0, + &BlockAuthorizer::MacVector(&keyrings[1]), + None, + None, + Vec::new(), + Vec::new(), + None, + ConsensusProtocol::Starfish, + None, + None, + None, + None, + None, + None, + None, + ); + full.preserialize(); + let reference = *full.reference(); + + let mut tagged = full.with_recipient_mac(0).unwrap(); + tagged.preserialize(); + let dag_state = open_test_dag_state_for("starfish-mac", 0); + dag_state.insert_general_block(Data::new(tagged), DataSource::BlockBundleStreaming); + + assert!(matches!( + dag_state + .get_storage_block(reference) + .unwrap() + .authentication(), + BlockAuthentication::MacTag(_) + )); + assert!(matches!( + dag_state.get_blocks_by_round_cached(1)[0].authentication(), + BlockAuthentication::MacTag(_) + )); + assert!(dag_state.upgrade_mac_authentication(&full)); + + let upgraded = dag_state.get_storage_block(reference).unwrap(); + assert!(upgraded.has_full_mac_vector()); + assert!(upgraded.with_recipient_mac(2).is_some()); + assert!(dag_state.get_blocks_by_round_cached(1)[0].has_full_mac_vector()); + let persisted = dag_state.store.get_block(&reference).unwrap().unwrap(); + assert!(persisted.has_full_mac_vector()); + assert!(!dag_state.upgrade_mac_authentication(&full)); + } + #[test] fn batch_vertex_certification_waits_for_parent_closure() { let dag_state = open_test_dag_state_for("sailfish-pp", 0); @@ -3742,6 +4187,253 @@ mod tests { assert!(dag_state.has_clean_vertex(&child_ref)); } + #[test] + fn starfish_rbc_delivery_is_order_independent_and_waits_for_ack_closure() { + let dag_state = open_test_dag_state_for("starfish-rbc", 0); + let genesis: Vec<_> = (0..4) + .map(|auth| BlockReference::new_test(auth, 0)) + .collect(); + let parent_a = make_starfish_rbc_block(0, 1, genesis.clone(), Vec::new()); + let parent_b = make_starfish_rbc_block(1, 1, genesis.clone(), Vec::new()); + let parent_c = make_starfish_rbc_block(2, 1, genesis, Vec::new()); + let parent_a_ref = *parent_a.reference(); + let parent_b_ref = *parent_b.reference(); + let parent_c_ref = *parent_c.reference(); + // parent_b is both a causal parent and a compressed logical ack. It + // must contribute only one missing dependency/wakeup. + let child = make_starfish_rbc_block( + 3, + 2, + vec![parent_a_ref, parent_b_ref, parent_c_ref], + vec![parent_b_ref], + ); + let child_ref = *child.reference(); + + dag_state.insert_general_blocks( + vec![parent_a, parent_b, parent_c, child], + DataSource::BlockBundleStreaming, + ); + + assert!(!dag_state.apply_starfish_rbc_delivery_refs_for_test(&[child_ref])); + assert!(!dag_state.has_clean_vertex(&child_ref)); + assert!( + dag_state.apply_starfish_rbc_delivery_refs_for_test(&[parent_a_ref, parent_c_ref,]) + ); + assert!(!dag_state.has_clean_vertex(&child_ref)); + assert!(dag_state.apply_starfish_rbc_delivery_refs_for_test(&[parent_b_ref])); + assert!(dag_state.has_clean_vertex(&child_ref)); + } + + #[test] + fn starfish_rbc_delivery_before_dirty_insertion_is_latched() { + let dag_state = open_test_dag_state_for("starfish-rbc", 0); + let genesis: Vec<_> = (0..4) + .map(|auth| BlockReference::new_test(auth, 0)) + .collect(); + let block = make_starfish_rbc_block(1, 1, genesis, Vec::new()); + let block_ref = *block.reference(); + + assert!( + !dag_state.apply_starfish_rbc_delivery_refs_for_test(&[block_ref]), + "delivery is retained but cannot activate an absent dirty carrier" + ); + assert!(!dag_state.has_clean_vertex(&block_ref)); + + dag_state.insert_general_block(block, DataSource::BlockBundleStreaming); + assert!( + dag_state.has_clean_vertex(&block_ref), + "later dirty insertion must consume the exact-reference delivery latch" + ); + } + + #[test] + fn starfish_rbc_disables_reference_inferred_cleanliness() { + let dag_state = open_test_dag_state_for("starfish-rbc", 0); + let genesis: Vec<_> = (0..4) + .map(|auth| BlockReference::new_test(auth, 0)) + .collect(); + let target = make_starfish_rbc_block(1, 1, genesis.clone(), Vec::new()); + let filler_a = make_starfish_rbc_block(0, 1, genesis.clone(), Vec::new()); + let filler_b = make_starfish_rbc_block(2, 1, genesis, Vec::new()); + let target_ref = *target.reference(); + let round_one_refs = vec![target_ref, *filler_a.reference(), *filler_b.reference()]; + let supporter_a = make_starfish_rbc_block(0, 2, round_one_refs.clone(), Vec::new()); + let supporter_b = make_starfish_rbc_block(2, 2, round_one_refs, Vec::new()); + + dag_state.insert_general_blocks( + vec![target, filler_a, filler_b, supporter_a, supporter_b], + DataSource::BlockBundleStreaming, + ); + + assert!( + !dag_state.has_clean_vertex(&target_ref), + "f+1 dirty descendants are not a Starfish-RBC delivery certificate" + ); + } + + #[test] + fn starfish_rbc_rejects_bls_verification_as_a_clean_capability() { + let dag_state = open_test_dag_state_for("starfish-rbc", 0); + let genesis: Vec<_> = (0..4) + .map(|auth| BlockReference::new_test(auth, 0)) + .collect(); + let block = make_starfish_rbc_block(1, 1, genesis, Vec::new()); + let block_ref = *block.reference(); + + // Neither event ordering may bypass the typed RBC delivery boundary. + assert!( + !dag_state.apply_certificate_events(vec![CertificateEvent::BlockVerified(block_ref,)]) + ); + dag_state.insert_general_block(block, DataSource::BlockBundleStreaming); + assert!(!dag_state.has_clean_vertex(&block_ref)); + assert!( + !dag_state.apply_certificate_events(vec![CertificateEvent::BlockVerified(block_ref,)]) + ); + assert!(!dag_state.has_clean_vertex(&block_ref)); + + assert!(dag_state.apply_starfish_rbc_delivery_refs_for_test(&[block_ref])); + assert!(dag_state.has_clean_vertex(&block_ref)); + } + + #[test] + fn starfish_rbc_dirty_quorum_cannot_advance_proposal_readiness() { + let dag_state = open_test_dag_state_for("starfish-rbc", 0); + let committee = Committee::new_for_benchmarks(4); + let genesis: Vec<_> = (0..4) + .map(|auth| BlockReference::new_test(auth, 0)) + .collect(); + let round_one: Vec<_> = (0..3) + .map(|authority| make_starfish_rbc_block(authority, 1, genesis.clone(), Vec::new())) + .collect(); + let round_one_refs: Vec<_> = round_one.iter().map(|block| *block.reference()).collect(); + + dag_state.insert_general_blocks(round_one, DataSource::BlockBundleStreaming); + assert_eq!(dag_state.threshold_clock_round(), 2); + assert_eq!(dag_state.proposal_round(), 1); + assert!(!dag_state.is_ready_for_new_block( + 2, + &[committee.elect_leader(1)], + false, + 0, + committee.as_ref(), + )); + + let version_before_delivery = dag_state.round_version(1); + assert!(dag_state.apply_starfish_rbc_delivery_refs_for_test(&round_one_refs)); + assert_eq!(dag_state.proposal_round(), 2); + assert!(dag_state.is_ready_for_new_block( + 2, + &[committee.elect_leader(1)], + false, + 0, + committee.as_ref(), + )); + assert!( + dag_state.round_version(1) > version_before_delivery, + "clean activation must invalidate consensus round caches" + ); + } + + #[test] + fn starfish_rbc_single_dag_dirty_quorum_advances_production_not_clean_consensus() { + let dag_state = open_test_dag_state_for("starfish-rbc-single-dag", 0); + let committee = Committee::new_for_benchmarks(4); + let genesis: Vec<_> = (0..4) + .map(|auth| BlockReference::new_test(auth, 0)) + .collect(); + let round_one: Vec<_> = (0..4) + .map(|authority| make_starfish_rbc_single_dag_block(authority, 1, genesis.clone())) + .collect(); + let round_one_refs: Vec<_> = round_one.iter().map(|block| *block.reference()).collect(); + + dag_state.insert_general_blocks(round_one, DataSource::BlockBundleStreaming); + + assert_eq!(dag_state.threshold_clock_round(), 2); + assert_eq!(dag_state.proposal_round(), 2); + assert!(!dag_state.clean_parent_quorum(1)); + assert!( + round_one_refs + .iter() + .all(|reference| !dag_state.has_clean_vertex(reference)) + ); + assert!(dag_state.is_ready_for_new_block( + 2, + &[committee.elect_leader(1)], + false, + 0, + committee.as_ref(), + )); + } + + #[test] + fn starfish_rbc_acknowledgment_waits_for_both_cleanliness_and_data() { + let dag_state = open_test_dag_state_for("starfish-rbc", 0); + let genesis: Vec<_> = (0..4) + .map(|auth| BlockReference::new_test(auth, 0)) + .collect(); + let transactions = vec![BaseTransaction::Share(Transaction::new(vec![1, 2, 3]))]; + let mut encoder = Encoder::new(2, 4, 2).unwrap(); + let encoded = encoder.encode_transactions(&transactions, 2, 2); + + // Data first: insertion marks availability, but the dirty header must + // not enter the pending acknowledgment queue. + let mut data_first = VerifiedBlock::new_starfish_rbc( + 1, + 1, + genesis.clone(), + Vec::new(), + 1, + transactions.clone(), + Some(encoded.clone()), + ); + data_first.preserialize(); + let data_first = Data::new(data_first); + let data_first_ref = *data_first.reference(); + dag_state.insert_general_block(data_first, DataSource::BlockBundleStreaming); + assert!(dag_state.is_data_available(&data_first_ref)); + assert!(dag_state.get_pending_acknowledgment(1).is_empty()); + assert!(dag_state.apply_starfish_rbc_delivery_refs_for_test(&[data_first_ref])); + assert_eq!( + dag_state.get_pending_acknowledgment(1), + vec![data_first_ref] + ); + + // Clean first: the header carries the non-empty commitment but no + // local payload. Attaching the verified payload later queues it once. + let mut clean_first = VerifiedBlock::new_starfish_rbc( + 2, + 1, + genesis, + Vec::new(), + 2, + Vec::new(), + Some(encoded.clone()), + ); + clean_first.preserialize(); + let clean_first = Data::new(clean_first); + let clean_first_ref = *clean_first.reference(); + dag_state.insert_general_block(clean_first, DataSource::BlockBundleStreaming); + assert!(dag_state.apply_starfish_rbc_delivery_refs_for_test(&[clean_first_ref])); + assert!(dag_state.get_pending_acknowledgment(1).is_empty()); + + let mut transaction_data = TransactionData::new(transactions); + transaction_data.preserialize(); + let (commitment, proof) = + TransactionsCommitment::new_from_encoded_transactions(&encoded, 2); + let mut shard = ProvableShard::new(encoded[2].clone(), 2, proof, commitment); + shard.preserialize(); + assert!(dag_state.attach_transaction_data( + clean_first_ref, + &transaction_data, + &shard, + DataSource::ShardReconstructor, + )); + assert_eq!( + dag_state.get_pending_acknowledgment(1), + vec![clean_first_ref] + ); + } + #[test] fn sailfish_pending_certified_refs_are_buffered_until_flushed() { let dag_state = open_test_dag_state_for("sailfish-pp", 0); @@ -4748,6 +5440,17 @@ mod tests { ConsensusProtocol::Starfish.default_dissemination_mode(), DisseminationMode::PushUseful ); + assert_eq!( + ConsensusProtocol::StarfishRbc.default_dissemination_mode(), + DisseminationMode::PushUseful + ); + assert!(ConsensusProtocol::StarfishRbc.is_starfish_rbc()); + assert!(ConsensusProtocol::StarfishRbc.uses_dual_dag()); + assert!(ConsensusProtocol::StarfishRbcSingleDag.is_starfish_rbc_single_dag()); + assert_eq!( + ConsensusProtocol::StarfishRbcSingleDag.default_dissemination_mode(), + DisseminationMode::PushUseful + ); assert_eq!( ConsensusProtocol::StarfishSpeed.default_dissemination_mode(), DisseminationMode::PushUseful @@ -4762,4 +5465,78 @@ mod tests { DisseminationMode::PushCausal ); } + + #[test] + fn protocol_config_selects_block_authentication() { + let protocols = [ + ("mysticeti", ConsensusProtocol::Mysticeti), + ("cordial-miners", ConsensusProtocol::CordialMiners), + ("starfish", ConsensusProtocol::Starfish), + ("starfish-rbc", ConsensusProtocol::StarfishRbc), + ( + "starfish-rbc-single-dag", + ConsensusProtocol::StarfishRbcSingleDag, + ), + ("starfish-speed", ConsensusProtocol::StarfishSpeed), + ("starfish-bls", ConsensusProtocol::StarfishBls), + ("sailfish-pp", ConsensusProtocol::SailfishPlusPlus), + ("bluestreak", ConsensusProtocol::Bluestreak), + ("mysticeti-bls", ConsensusProtocol::MysticetiBls), + ( + "sparse-starfish-speed", + ConsensusProtocol::SparseStarfishSpeed, + ), + ]; + let signature_schemes = [ + (None, BlockAuthenticationScheme::Ed25519), + (Some("ed25519"), BlockAuthenticationScheme::Ed25519), + (Some("ml-dsa-44"), BlockAuthenticationScheme::MlDsa44), + (Some("ml-dsa-65"), BlockAuthenticationScheme::MlDsa65), + ]; + + for (name, consensus_protocol) in protocols { + for (selection, block_authentication_scheme) in signature_schemes { + assert_eq!( + ProtocolConfig::from_selection(name, selection).unwrap(), + ProtocolConfig { + consensus_protocol, + block_authentication_scheme, + } + ); + } + } + + assert_eq!( + ProtocolConfig::from_selection("starfish-rbc", Some("mac")).unwrap(), + ProtocolConfig { + consensus_protocol: ConsensusProtocol::StarfishRbc, + block_authentication_scheme: BlockAuthenticationScheme::MacVector, + } + ); + assert!(ProtocolConfig::from_selection("starfish", Some("mac")).is_err()); + assert!(ProtocolConfig::from_str("starfish-rbc-mac").is_err()); + + for (name, consensus_protocol) in [ + ("starfish-mac", ConsensusProtocol::Starfish), + ("starfish-speed-mac", ConsensusProtocol::StarfishSpeed), + ( + "sparse-starfish-speed-mac", + ConsensusProtocol::SparseStarfishSpeed, + ), + ("bluestreak-mac", ConsensusProtocol::Bluestreak), + ] { + assert_eq!( + ProtocolConfig::from_str(name).unwrap(), + ProtocolConfig { + consensus_protocol, + block_authentication_scheme: BlockAuthenticationScheme::MacVector, + } + ); + assert!(ProtocolConfig::from_selection(name, Some("ed25519")).is_err()); + } + + assert!(ProtocolConfig::from_str("mysticeti-mac").is_err()); + assert!(ProtocolConfig::from_selection("starfish", Some("unknown")).is_err()); + assert!(ProtocolConfig::from_str("starfish-unknown").is_err()); + } } diff --git a/crates/starfish-core/src/lib.rs b/crates/starfish-core/src/lib.rs index ea6e6142..bd6068cc 100644 --- a/crates/starfish-core/src/lib.rs +++ b/crates/starfish-core/src/lib.rs @@ -29,6 +29,8 @@ pub mod prometheus; mod rocks_store; mod runtime; pub mod shard_reconstructor; +pub mod starfish_rbc; +mod starfish_rbc_service; mod stat; mod state; pub(crate) mod store; diff --git a/crates/starfish-core/src/metrics.rs b/crates/starfish-core/src/metrics.rs index 77bf4bf7..15004578 100644 --- a/crates/starfish-core/src/metrics.rs +++ b/crates/starfish-core/src/metrics.rs @@ -132,6 +132,8 @@ pub struct Metrics { // per-request-type network message counters pub network_requests_sent_total: IntCounterVec, pub network_requests_received_total: IntCounterVec, + pub network_message_bytes_sent_total: IntCounterVec, + pub network_message_bytes_received_total: IntCounterVec, // subscription tracking pub subscribed_to_peers: IntGauge, @@ -500,6 +502,20 @@ impl Metrics { registry, ) .unwrap(), + network_message_bytes_sent_total: register_int_counter_vec_with_registry!( + "network_message_bytes_sent_total", + "Total framed network-message bytes sent, by type", + &["request_type"], + registry, + ) + .unwrap(), + network_message_bytes_received_total: register_int_counter_vec_with_registry!( + "network_message_bytes_received_total", + "Total framed network-message bytes received, by type", + &["request_type"], + registry, + ) + .unwrap(), subscribed_to_peers: register_int_gauge_with_registry!( "subscribed_to_peers", "Number of peers this validator is subscribed to", @@ -1074,6 +1090,72 @@ impl Metrics { b->"Average bandwidth in:", format!("{:.2} MB/s", bw_in) ]); + const NETWORK_MESSAGE_TYPES: &[&str] = &[ + "subscribe_broadcast", + "batch", + "missing_parents", + "missing_tx_data", + "partial_sig", + "cert_echo", + "cert_vote", + "cert_ready", + "cert_batch", + "sailfish_timeout", + "sailfish_no_vote", + "unprovable_cert_request", + "round_gap_request", + "rbc_initial", + "rbc_echo", + "rbc_ready", + "rbc_header_request", + "rbc_header_response", + ]; + let outbound_message_breakdown = NETWORK_MESSAGE_TYPES + .iter() + .filter_map(|request_type| { + let average_bytes = metrics + .iter() + .map(|metrics| { + metrics + .network_message_bytes_sent_total + .with_label_values(&[request_type]) + .get() + }) + .sum::() as f64 + / num_validators as f64; + if average_bytes == 0.0 { + return None; + } + let average_requests = metrics + .iter() + .map(|metrics| { + metrics + .network_requests_sent_total + .with_label_values(&[request_type]) + .get() + }) + .sum::() as f64 + / num_validators as f64; + Some((*request_type, average_bytes, average_requests)) + }) + .collect::>(); + if !outbound_message_breakdown.is_empty() { + table.add_row(row![bH2->""]); + table.add_row(row![bH2->"Average Outbound Message Breakdown"]); + for (request_type, average_bytes, average_requests) in outbound_message_breakdown { + let bandwidth = average_bytes / duration_secs as f64 / 1024.0 / 1024.0; + let share = if average_bytes_sent == 0 { + 0.0 + } else { + average_bytes / average_bytes_sent as f64 * 100.0 + }; + let requests_per_second = average_requests / duration_secs as f64; + table.add_row(row![ + b->format!("{request_type}:"), + format!("{bandwidth:.3} MB/s ({share:.1}%, {requests_per_second:.1} msg/s)") + ]); + } + } let total_average_transactions = (average_tps * duration_secs as f64) as u64; let bandwidth_efficiency = if total_average_transactions > 0 { average_bytes_sent as f64 / total_average_transactions as f64 / 512.0 diff --git a/crates/starfish-core/src/net_sync.rs b/crates/starfish-core/src/net_sync.rs index cef8dd42..665db02a 100644 --- a/crates/starfish-core/src/net_sync.rs +++ b/crates/starfish-core/src/net_sync.rs @@ -34,7 +34,7 @@ use crate::{ }, core::Core, core_thread::CoreThreadDispatcher, - crypto::BlsSigner, + crypto::{BlsSigner, MacKey}, dag_state::{ConsensusProtocol, DagState, DataSource}, data::Data, metrics::{Metrics, UtilizationTimerVecExt}, @@ -44,16 +44,141 @@ use crate::{ SailfishCertEvent, SailfishServiceHandle, SailfishServiceMessage, start_sailfish_service, }, shard_reconstructor::{DecodedBlocks, ShardMessage, start_shard_reconstructor}, - syncer::{CommitObserver, Syncer, SyncerSignals}, + starfish_rbc::{RbcCanonicalHeader, RbcProtocolInstanceId}, + starfish_rbc_service::{ + RbcInitialAuthenticator, RbcPhaseAuthorityV1, RbcServiceEvent, RbcServiceHandle, + start_starfish_rbc_service_with_phase_authority, + }, + syncer::{CommitObserver, STARFISH_RBC_SINGLE_DAG_ROUND_INTERVAL, Syncer, SyncerSignals}, types::{ - AuthorityIndex, AuthoritySet, BlockDigest, BlockReference, PartialSig, PartialSigKind, - ProvableShard, RoundNumber, VerifiedBlock, format_authority_index, + AuthorityIndex, AuthoritySet, BlockAuthentication, BlockAuthenticationScheme, BlockDigest, + BlockReference, PartialSig, PartialSigKind, ProvableShard, ReconstructedTransactionData, + RoundNumber, TransactionData, VerifiedBlock, format_authority_index, }, }; const MAX_FILTER_SIZE: usize = 100_000; const SAILFISH_CERT_BATCH_FLUSH_INTERVAL: Duration = Duration::from_millis(5); const SAILFISH_CERT_BATCH_MAX_LEN: usize = 256; +const STARFISH_RBC_HEADER_RETRY_INTERVAL: Duration = Duration::from_millis(250); + +/// Enforce the MAC experiment's transport contract before cryptographic +/// verification: +/// +/// - a full vector is accepted only on proactive block streaming directly from +/// the block's claimed author; +/// - every relay and synchronization path must carry one recipient tag; +/// - a direct author stream must carry the full vector, so recipients retain +/// the material needed for one-hop relay. +fn verify_mac_transport( + block: &VerifiedBlock, + authentication_scheme: BlockAuthenticationScheme, + peer_id: AuthorityIndex, + source: DataSource, +) -> eyre::Result<()> { + if authentication_scheme != BlockAuthenticationScheme::MacVector { + return Ok(()); + } + + let direct_author_stream = peer_id == block.authority() + && matches!( + source, + DataSource::BlockBundleStreaming | DataSource::BlockBundleStreamingHeader + ); + + match block.authentication() { + BlockAuthentication::MacVector(_) if direct_author_stream => Ok(()), + BlockAuthentication::MacVector(_) => eyre::bail!( + "Full MAC vector for block {} must arrive via direct author block streaming; \ + received from authority {} with source {}", + block.reference(), + peer_id, + source, + ), + BlockAuthentication::MacTag(_) if !direct_author_stream => Ok(()), + BlockAuthentication::MacTag(_) => eyre::bail!( + "Direct author block stream for block {} must carry the full MAC vector", + block.reference(), + ), + _ => Ok(()), + } +} + +fn verify_starfish_rbc_transaction_payload( + canonical_header: &RbcCanonicalHeader, + transaction_data: Arc, + committee: &Committee, + own_id: AuthorityIndex, + peer_id: AuthorityIndex, + encoder: &mut ReedSolomonEncoder, + authentication_scheme: BlockAuthenticationScheme, + mac_keys: &[MacKey], +) -> eyre::Result { + let block_reference = canonical_header.reference(); + let transaction_data = Arc::try_unwrap(transaction_data).unwrap_or_else(|data| (*data).clone()); + let (block_header, _) = canonical_header.to_authentication_free_block().into_parts(); + let mut block = VerifiedBlock::from_parts(block_header, Some(transaction_data)); + let Some(mut shard_data) = block.verify_with_authentication( + committee, + own_id as usize, + peer_id as usize, + encoder, + ConsensusProtocol::StarfishRbc, + authentication_scheme, + mac_keys, + )? + else { + eyre::bail!("Starfish-RBC transaction payload for {block_reference} is empty"); + }; + block.preserialize(); + shard_data.preserialize(); + let transaction_data = block + .transaction_data() + .expect("verified RBC payload must contain transaction data") + .clone(); + Ok(ReconstructedTransactionData { + block_reference, + transaction_data, + shard_data, + }) +} + +/// Prepare blocks forwarded through relay or synchronization paths for a +/// specific peer. Legacy MAC-experiment blocks retain their complete vector +/// only at direct recipients; forwarding selects the destination's tag. A +/// tag-only copy cannot be forwarded again and is therefore omitted. +/// Direct Starfish-RBC header blocks are authentication-free and remain +/// forwardable: the separate RBC service controls clean admission. +pub(crate) fn prepare_forwarded_blocks_for_peer( + authentication_scheme: BlockAuthenticationScheme, + consensus_protocol: ConsensusProtocol, + recipient: AuthorityIndex, + blocks: Vec>, +) -> Vec> { + if authentication_scheme != BlockAuthenticationScheme::MacVector + || consensus_protocol.is_starfish_rbc() + { + return blocks; + } + + blocks + .into_iter() + .filter_map(|block| { + block + .with_recipient_mac(recipient) + .map(Data::new) + .or_else(|| { + tracing::debug!( + "Cannot forward MAC-authenticated block {} to authority {}: \ + complete MAC vector is unavailable", + block.reference(), + recipient, + ); + None + }) + }) + .collect() +} async fn send_network_message_reliably( sender: &mpsc::Sender, @@ -161,6 +286,7 @@ fn eligible_missing_parent_refs( struct FilterForBlocks { digests: parking_lot::RwLock>, + full_mac_vectors: parking_lot::RwLock>, queue: parking_lot::RwLock>, } @@ -168,6 +294,7 @@ impl FilterForBlocks { fn new() -> Self { Self { digests: parking_lot::RwLock::new(AHashSet::new()), + full_mac_vectors: parking_lot::RwLock::new(AHashSet::new()), queue: parking_lot::RwLock::new(VecDeque::new()), } } @@ -177,58 +304,84 @@ impl FilterForBlocks { digests.iter().map(|d| set.contains(d)).collect() } - fn insert_batch(&self, new_digests: &[BlockDigest]) { + fn contains_full_mac_batch(&self, digests: &[BlockDigest]) -> Vec { + let set = self.full_mac_vectors.read(); + digests.iter().map(|d| set.contains(d)).collect() + } + + fn insert_batch(&self, blocks: &[(BlockDigest, bool)]) { let mut digests = self.digests.write(); + let mut full_mac_vectors = self.full_mac_vectors.write(); let mut queue = self.queue.write(); - for digest in new_digests { + for (digest, has_full_mac_vector) in blocks { if digests.insert(*digest) { queue.push_back(*digest); } + if *has_full_mac_vector { + full_mac_vectors.insert(*digest); + } } while queue.len() > MAX_FILTER_SIZE { if let Some(removed) = queue.pop_front() { digests.remove(&removed); + full_mac_vectors.remove(&removed); } } } - /// Inserts all digests and returns `true` for each that was genuinely new - /// (not already in the filter and not duplicated earlier in the batch). - fn insert_and_report_new(&self, digests: &[BlockDigest]) -> Vec { + /// Inserts all verified copies and returns `true` for each copy that adds + /// either a new block reference or the first full MAC vector for a + /// previously recipient-tag-only reference. + fn insert_and_report_useful(&self, blocks: &[(BlockDigest, bool)]) -> Vec { let mut set = self.digests.write(); + let mut full_mac_vectors = self.full_mac_vectors.write(); let mut queue = self.queue.write(); - let is_new: Vec = digests + let is_useful: Vec = blocks .iter() - .map(|d| { - if set.insert(*d) { - queue.push_back(*d); - true - } else { - false + .map(|(digest, has_full_mac_vector)| { + let is_new = set.insert(*digest); + if is_new { + queue.push_back(*digest); } + let is_mac_upgrade = *has_full_mac_vector && full_mac_vectors.insert(*digest); + is_new || is_mac_upgrade }) .collect(); while queue.len() > MAX_FILTER_SIZE { if let Some(removed) = queue.pop_front() { set.remove(&removed); + full_mac_vectors.remove(&removed); } } - is_new + is_useful } - /// For each header digest, returns `true` if the digest has not been seen - /// before (neither in the filter nor earlier in this batch). - fn needed_headers(&self, batch: &[BlockDigest]) -> Vec { + /// For each header, returns `true` if it is either unseen or upgrades a + /// previously seen recipient-only MAC to a full vector. + fn needed_headers(&self, batch: &[(BlockDigest, bool)]) -> Vec { let digests = self.digests.read(); - let mut seen_in_batch = AHashSet::with_capacity(batch.len()); + let full_mac_vectors = self.full_mac_vectors.read(); + let mut seen_in_batch = AHashMap::with_capacity(batch.len()); batch .iter() - .map(|digest| !digests.contains(digest) && seen_in_batch.insert(*digest)) + .map(|(digest, has_full_mac_vector)| { + let was_seen = digests.contains(digest) || seen_in_batch.contains_key(digest); + let had_full_mac_vector = seen_in_batch + .get(digest) + .copied() + .unwrap_or_else(|| full_mac_vectors.contains(digest)); + let is_needed = !was_seen || (*has_full_mac_vector && !had_full_mac_vector); + seen_in_batch + .entry(*digest) + .and_modify(|full| *full |= *has_full_mac_vector) + .or_insert(*has_full_mac_vector); + is_needed + }) .collect() } } @@ -424,8 +577,11 @@ fn spawn_header_worker( let mut encoder = ReedSolomonEncoder::new(2, 4, 2).expect("Encoder should be created"); while let Some((blocks, source)) = rx.recv().await { let connection_knowledge = inner.cordial_knowledge.connection_knowledge(peer_id); - let incoming_digests: Vec<_> = blocks.iter().map(|block| block.digest()).collect(); - let needed_before_verify = filter_for_blocks.needed_headers(&incoming_digests); + let incoming_headers: Vec<_> = blocks + .iter() + .map(|block| (block.digest(), block.has_full_mac_vector())) + .collect(); + let needed_before_verify = filter_for_blocks.needed_headers(&incoming_headers); let mut verified_blocks: Vec = Vec::new(); for (data_block, is_needed) in blocks.into_iter().zip(needed_before_verify) { @@ -435,12 +591,28 @@ fn spawn_header_worker( } let mut block: VerifiedBlock = (*data_block).clone(); tracing::debug!("Received {} from {}", block, peer); - match block.verify( + if let Err(e) = verify_mac_transport( + &block, + inner.dag_state.block_authentication_scheme, + peer_id, + source, + ) { + tracing::warn!( + "Rejected incorrectly transported block {} from {}: {:?}", + block.reference(), + peer, + e + ); + break; + } + match block.verify_with_authentication( &inner.committee, own_id as usize, peer_id as usize, &mut encoder, consensus_protocol, + inner.dag_state.block_authentication_scheme, + &inner.mac_keys, ) { Ok(shard) => { debug_assert!(shard.is_none(), "shard must be None for header-only blocks") @@ -464,11 +636,14 @@ fn spawn_header_worker( ck.mark_headers_useful_from_peer(&refs); } - let digests: Vec<_> = verified_blocks.iter().map(|b| b.digest()).collect(); - let is_new = filter_for_blocks.insert_and_report_new(&digests); + let filter_entries: Vec<_> = verified_blocks + .iter() + .map(|block| (block.digest(), block.has_full_mac_vector())) + .collect(); + let is_useful = filter_for_blocks.insert_and_report_useful(&filter_entries); let mut new_data_blocks = Vec::new(); - for (storage_block, is_new) in verified_blocks.into_iter().zip(is_new) { - if is_new { + for (storage_block, is_useful) in verified_blocks.into_iter().zip(is_useful) { + if is_useful { let mut storage_block = storage_block; storage_block.preserialize(); debug_assert!( @@ -566,6 +741,7 @@ struct ConnectionHandler header_tx: mpsc::UnboundedSender<(Vec>, DataSource)>, bls_service: Option, sailfish_service: Option, + starfish_rbc_service: Option, } impl ConnectionHandler { @@ -607,6 +783,7 @@ impl ConnectionHandler ConnectionHandler ConnectionHandler ConnectionHandler { + if let Some(ref rbc) = self.starfish_rbc_service { + if let Err(error) = rbc.direct_initial(self.peer_id, proposal) { + tracing::warn!("Failed to forward Starfish-RBC INIT: {error}"); + } + } + } + NetworkMessage::RbcPhase(message) => { + if let Some(ref rbc) = self.starfish_rbc_service { + if let Err(error) = rbc.phase(self.peer_id, message) { + tracing::warn!("Failed to forward Starfish-RBC phase: {error}"); + } + } + } + NetworkMessage::RbcHeaderRequest(block_ref) => { + if let Some(ref rbc) = self.starfish_rbc_service { + if let Err(error) = rbc.header_request(self.peer_id, block_ref) { + tracing::warn!("Failed to forward Starfish-RBC header request: {error}"); + } + } + } + NetworkMessage::RbcHeaderResponse(header) => { + if let Some(ref rbc) = self.starfish_rbc_service { + if let Err(error) = rbc.header_response(self.peer_id, header) { + tracing::warn!("Failed to forward Starfish-RBC header response: {error}"); + } + } + } } true } @@ -889,6 +1096,8 @@ impl ConnectionHandler { @@ -913,6 +1122,7 @@ impl ConnectionHandler ConnectionHandler)> = Vec::new(); - for ((data_block, _digest), (bk, sf)) in blocks - .into_iter() - .zip(incoming_digests) - .zip(block_known.into_iter().zip(shard_full)) - { - if bk && sf { + for (index, data_block) in blocks.into_iter().enumerate() { + let bk = block_known[index]; + let sf = shard_full[index]; + let incoming_has_full_mac = data_block.has_full_mac_vector(); + if bk && sf && (!incoming_has_full_mac || full_mac_known[index]) { self.metrics.filtered_blocks_total.inc(); continue; } let mut block: VerifiedBlock = (*data_block).clone(); tracing::debug!("Received {} from {}", block, self.peer); - let shard = match block.verify( + if let Err(e) = verify_mac_transport( + &block, + self.inner.dag_state.block_authentication_scheme, + self.peer_id, + source, + ) { + tracing::warn!( + "Rejected incorrectly transported block {} from {}: {:?}", + block.reference(), + self.peer, + e + ); + break; + } + let shard = match block.verify_with_authentication( &self.inner.committee, self.own_id as usize, self.peer_id as usize, &mut self.encoder, self.consensus_protocol, + self.inner.dag_state.block_authentication_scheme, + &self.inner.mac_keys, ) { Ok(shard) => shard, Err(e) => { @@ -1008,8 +1236,16 @@ impl ConnectionHandler = verified.iter().map(|(b, _)| b.digest()).collect(); - self.filter_for_blocks.insert_batch(&verified_digests); + let verified_filter_entries: Vec<_> = verified + .iter() + .map(|(block, _)| (block.digest(), block.has_full_mac_vector())) + .collect(); + let verified_digests: Vec<_> = verified_filter_entries + .iter() + .map(|(digest, _)| *digest) + .collect(); + self.filter_for_blocks + .insert_batch(&verified_filter_entries); self.filter_for_shards.mark_full_batch(&verified_digests); // --- preserialize + collect --- @@ -1120,6 +1356,7 @@ impl ConnectionHandler ConnectionHandler ConnectionHandler ConnectionHandler { bls_event_task: Option>, bls_broadcast_task: Option>, sf_event_task: Option>, + rbc_event_task: Option>, + rbc_service_task: Option>, cordial_knowledge_task: JoinHandle<()>, } @@ -1329,6 +1581,7 @@ pub struct NetworkSyncerInner { pub block_ready_notify: Arc, pub proposal_round_notify: Arc, pub committee: Arc, + pub mac_keys: Arc>, pub dissemination_mode: DisseminationMode, pub causal_push_shard_round_lag: RoundNumber, stop: mpsc::Sender<()>, @@ -1339,11 +1592,19 @@ pub struct NetworkSyncerInner { pub cordial_knowledge: CordialKnowledgeHandle, /// Per-peer message senders for direct unicast (e.g. DAC partial sigs). pub peer_senders: parking_lot::RwLock>>, + /// Nonblocking ingress to per-connection RBC outbound workers. Keeping + /// these queues separate prevents one backpressured peer from delaying + /// another peer or the actor's local HeaderStaged/Delivered effects. + rbc_peer_senders: + parking_lot::RwLock>>, pub leader_timeout: Duration, pub soft_block_timeout: Duration, /// Sailfish++ service handle for sending control messages /// (timeout/no-vote). None for non-SailfishPlusPlus protocols. pub sailfish_handle: Option, + /// Central Starfish-RBC service. Connection workers only forward their + /// trusted peer identity and wire payload into this single owner. + pub(crate) starfish_rbc_service: Option, /// Wall-clock at NetworkSyncer start; consumed by time-dependent /// Byzantine strategies (e.g. RampUpWithholding) to ramp behavior /// over a fixed schedule. @@ -1351,7 +1612,7 @@ pub struct NetworkSyncerInner { } impl NetworkSyncer { - pub fn start( + pub async fn start( network: Network, mut core: Core, mut commit_observer: C, @@ -1367,6 +1628,7 @@ impl NetworkSyncer let (committed, committed_leaders_count) = core.take_recovered_committed(); commit_observer.recover_committed(committed, committed_leaders_count); let committee = core.committee().clone(); + let mac_keys = core.mac_keys(); let dag_state = core.dag_state().clone(); let dissemination_mode = dag_state .consensus_protocol @@ -1397,7 +1659,50 @@ impl NetworkSyncer let sf_handle_for_inner = sf_msg_tx .as_ref() .map(|tx| SailfishServiceHandle::new(tx.clone())); - let mut syncer = Syncer::new( + let (starfish_rbc_service, rbc_event_rx, rbc_service_task) = + if dag_state.consensus_protocol.is_starfish_rbc() { + let protocol_instance = node_parameters + .starfish_rbc_protocol_instance + .and_then(|bytes| RbcProtocolInstanceId::new(bytes).ok()) + .expect( + "validated Starfish-RBC configuration must contain a nonzero protocol instance", + ); + let initial_authenticator = match dag_state.block_authentication_scheme { + BlockAuthenticationScheme::Ed25519 => { + RbcInitialAuthenticator::Ed25519(core.get_signer().clone()) + } + BlockAuthenticationScheme::MlDsa44 => { + RbcInitialAuthenticator::MlDsa44(core.get_ml_dsa_44_signer().clone()) + } + BlockAuthenticationScheme::MlDsa65 => { + RbcInitialAuthenticator::MlDsa65(core.get_ml_dsa_65_signer().clone()) + } + BlockAuthenticationScheme::MacVector => RbcInitialAuthenticator::Mac, + }; + let (service, events, task) = start_starfish_rbc_service_with_phase_authority( + committee.clone(), + dag_state.get_own_authority_index(), + protocol_instance, + dag_state.block_authentication_scheme, + mac_keys.clone(), + initial_authenticator, + dag_state.highest_round(), + STARFISH_RBC_HEADER_RETRY_INTERVAL, + if dag_state.consensus_protocol.is_starfish_rbc_single_dag() { + RbcPhaseAuthorityV1::EmbeddedSingleDag { + echo_qc_fast_path: node_parameters + .starfish_rbc_single_dag_echo_qc_fast_path, + } + } else { + RbcPhaseAuthorityV1::Direct + }, + ) + .expect("validated Starfish-RBC configuration must start its service"); + (Some(service), Some(events), Some(task)) + } else { + (None, None, None) + }; + let syncer = Syncer::new( core, NetworkSyncSignals { block_ready_notify: block_ready_notify.clone(), @@ -1407,10 +1712,14 @@ impl NetworkSyncer metrics.clone(), bls_msg_tx.clone(), sf_msg_tx.clone(), + starfish_rbc_service.clone(), ); let initial_round = syncer.core().next_block_round(); - syncer.force_new_block(initial_round); let syncer = CoreThreadDispatcher::start(syncer); + // Await the initial command while the async RBC actor remains + // schedulable. The command itself runs on the dedicated core thread, + // where synchronous local-INIT selection is safe. + syncer.force_new_block(initial_round).await; let (stop_sender, stop_receiver) = mpsc::channel(1); // Occupy the only available permit, so that all other // calls to send() will block. @@ -1422,6 +1731,7 @@ impl NetworkSyncer | ConsensusProtocol::StarfishSpeed | ConsensusProtocol::StarfishBls | ConsensusProtocol::SparseStarfishSpeed + | ConsensusProtocol::StarfishRbc ); let gc_round = Arc::new(AtomicU32::new(dag_state.gc_round())); let (shard_tx, decoded_rx) = if is_starfish { @@ -1459,6 +1769,7 @@ impl NetworkSyncer syncer, proposal_round_notify, committee, + mac_keys, dissemination_mode, causal_push_shard_round_lag: node_parameters.causal_push_shard_round_lag, stop: stop_sender.clone(), @@ -1466,12 +1777,134 @@ impl NetworkSyncer shard_tx: parking_lot::Mutex::new(shard_tx), cordial_knowledge: cordial_knowledge_handle, peer_senders: parking_lot::RwLock::new(AHashMap::new()), + rbc_peer_senders: parking_lot::RwLock::new(AHashMap::new()), leader_timeout: node_parameters.leader_timeout, soft_block_timeout: node_parameters.soft_block_timeout, sailfish_handle: sf_handle_for_inner, + starfish_rbc_service: starfish_rbc_service.clone(), start_time: std::time::Instant::now(), }); + // Bridge the single-owner RBC actor to direct network unicasts and to + // the core thread's dirty/clean DAG boundaries. Header staging never + // implies delivery; only a typed `Delivered` effect can mark a vertex + // clean. + let rbc_event_task = rbc_event_rx.map(|mut event_rx| { + let event_inner = inner.clone(); + handle.spawn(async move { + let mut payload_encoder = ReedSolomonEncoder::new(2, 4, 2) + .expect("Starfish-RBC payload encoder should be created"); + while let Some(event) = event_rx.recv().await { + match event { + RbcServiceEvent::Network { recipient, message } => { + let sender = + event_inner.rbc_peer_senders.read().get(&recipient).cloned(); + if let Some(sender) = sender { + if sender.send(message).is_err() { + tracing::debug!( + "Starfish-RBC outbound worker for authority {} stopped", + recipient + ); + } + } else { + // Local INITs and phase intents are retained by + // the actor and replayed when this peer connects. + tracing::debug!( + "Deferring Starfish-RBC message for disconnected authority {}", + recipient + ); + } + } + RbcServiceEvent::HeaderStaged(header) => { + let mut block = header.header().to_authentication_free_block(); + block.preserialize(); + let block_ref = *block.reference(); + event_inner + .cordial_knowledge + .send(CordialKnowledgeMessage::DagParts { + headers: vec![block_ref], + shards: Vec::new(), + }); + let (missing_parents, _) = event_inner + .syncer + .add_headers( + vec![Data::new(block)], + DataSource::BlockBundleStreamingHeader, + ) + .await; + if !missing_parents.is_empty() { + tracing::debug!( + "Starfish-RBC staged header {} waits for dependencies {:?}", + block_ref, + missing_parents + ); + } + } + RbcServiceEvent::TransactionPayloadStaged { + peer, + header, + transaction_data, + } => { + let block_ref = header.reference(); + let item = match verify_starfish_rbc_transaction_payload( + header.header(), + transaction_data, + &event_inner.committee, + event_inner.dag_state.get_own_authority_index(), + peer, + &mut payload_encoder, + event_inner.dag_state.block_authentication_scheme, + &event_inner.mac_keys, + ) { + Ok(item) => item, + Err(error) => { + tracing::warn!( + ?block_ref, + peer, + ?error, + "Rejected Starfish-RBC transaction payload" + ); + continue; + } + }; + event_inner + .cordial_knowledge + .send(CordialKnowledgeMessage::DagParts { + headers: Vec::new(), + shards: vec![block_ref], + }); + if let Some(shard_tx) = event_inner.shard_tx.lock().as_ref() { + let _ = shard_tx.send(vec![ShardMessage::FullBlock(block_ref)]); + } + event_inner + .syncer + .add_transaction_data(vec![item], DataSource::StarfishRbcPayload) + .await; + } + RbcServiceEvent::Delivered(header) => { + event_inner + .syncer + .apply_starfish_rbc_deliveries(vec![header]) + .await; + } + RbcServiceEvent::ReferenceReady(reference) => { + event_inner + .syncer + .apply_starfish_rbc_reference(reference) + .await; + } + RbcServiceEvent::Rejected { peer, error } => { + tracing::warn!( + "Rejected Starfish-RBC input from {:?}: {}", + peer, + error + ); + } + } + } + }) + }); + // Start bridge task that forwards reconstructed transaction data to core let bridge_task = decoded_rx.map(|mut decoded_rx| { let bridge_inner = inner.clone(); @@ -1755,6 +2188,8 @@ impl NetworkSyncer bls_event_task, bls_broadcast_task, sf_event_task, + rbc_event_task, + rbc_service_task, cordial_knowledge_task, } } @@ -1792,6 +2227,14 @@ impl NetworkSyncer sf_task.abort(); sf_task.await.ok(); } + // Stop RBC event ingress first, but keep the service actor alive while + // the core queue drains: an already queued core action may still + // synchronously select another local INIT. + if let Some(rbc_task) = self.rbc_event_task { + rbc_task.abort(); + rbc_task.await.ok(); + } + let rbc_service_task = self.rbc_service_task; // Stop the cordial knowledge actor. self.cordial_knowledge_task.abort(); self.cordial_knowledge_task.await.ok(); @@ -1817,7 +2260,16 @@ impl NetworkSyncer } } }; - inner.syncer.stop() + // `inner` is now exclusive, so no auxiliary task can enqueue after + // this FIFO barrier. Awaiting it keeps the runtime available to the + // RBC actor while any earlier core action completes. + let _ = inner.syncer.missing_parent_references().await; + let syncer = inner.syncer.stop(); + if let Some(rbc_service_task) = rbc_service_task { + rbc_service_task.abort(); + rbc_service_task.await.ok(); + } + syncer } async fn run( @@ -1948,6 +2400,25 @@ impl NetworkSyncer .peer_senders .write() .insert(peer_id, connection.sender.clone()); + let rbc_outbound_task = inner.starfish_rbc_service.as_ref().map(|_| { + let (rbc_sender, mut rbc_receiver) = mpsc::unbounded_channel(); + inner.rbc_peer_senders.write().insert(peer_id, rbc_sender); + let network_sender = connection.sender.clone(); + Handle::current().spawn(async move { + while let Some(message) = rbc_receiver.recv().await { + send_network_message_reliably(&network_sender, message).await; + } + }) + }); + if let Some(ref rbc) = inner.starfish_rbc_service { + if let Err(error) = rbc.peer_connected(peer_id) { + tracing::warn!( + "Failed to notify Starfish-RBC service that authority {} connected: {}", + peer_id, + error + ); + } + } if inner.dag_state.consensus_protocol.uses_bls() { for (round, signature) in inner.dag_state.precomputed_round_sigs() { @@ -1986,7 +2457,21 @@ impl NetworkSyncer } tracing::debug!("Connection between {own_id} and {peer_id} is dropped"); + if let Some(ref rbc) = inner.starfish_rbc_service { + if let Err(error) = rbc.peer_disconnected(peer_id) { + tracing::warn!( + "Failed to notify Starfish-RBC service that authority {} disconnected: {}", + peer_id, + error + ); + } + } inner.peer_senders.write().remove(&peer_id); + inner.rbc_peer_senders.write().remove(&peer_id); + if let Some(rbc_outbound_task) = rbc_outbound_task { + rbc_outbound_task.abort(); + rbc_outbound_task.await.ok(); + } inner.syncer.authority_connection(peer_id, false).await; handler.shutdown().await; block_fetcher.remove_authority(peer_id).await; @@ -2010,7 +2495,15 @@ impl NetworkSyncer for round in armed_round + 1..=current_round { let timer_inner = inner.clone(); Handle::current().spawn(async move { - let leader_timeout = timer_inner.leader_timeout; + let leader_timeout = if timer_inner + .dag_state + .consensus_protocol + .is_starfish_rbc_single_dag() + { + STARFISH_RBC_SINGLE_DAG_ROUND_INTERVAL + } else { + timer_inner.leader_timeout + }; select! { _sleep = sleep(leader_timeout) => { tracing::debug!("Timeout for proposal round {round}"); @@ -2363,8 +2856,9 @@ mod tests { use super::*; use crate::{ - crypto::SignatureBytes, - types::{BaseTransaction, BlockReference}, + crypto::{self, SignatureBytes, TransactionsCommitment}, + encoder::ShardEncoder, + types::{BaseTransaction, BlockReference, Transaction, TransactionData}, }; #[tokio::test] @@ -2381,6 +2875,201 @@ mod tests { wait.await; } + #[test] + fn starfish_rbc_initial_payload_is_commitment_checked() { + let committee = Committee::new_test(vec![1; 4]); + let transactions = vec![BaseTransaction::Share(Transaction::new(vec![7; 64]))]; + let mut commitment_encoder = + ReedSolomonEncoder::new(2, 4, 2).expect("encoder should be created"); + let encoded = commitment_encoder.encode_transactions( + &transactions, + committee.info_length(), + committee.len() - committee.info_length(), + ); + let (commitment, _) = TransactionsCommitment::new_from_encoded_transactions(&encoded, 1); + let canonical = RbcCanonicalHeader::try_new( + 0, + 1, + vec![ + BlockReference::new_test(0, 0), + BlockReference::new_test(1, 0), + BlockReference::new_test(2, 0), + ], + Vec::new(), + 11, + commitment, + ) + .unwrap(); + let payload = Arc::new(TransactionData::new(transactions.clone())); + let mut verifier = ReedSolomonEncoder::new(2, 4, 2).expect("encoder should be created"); + let verified = verify_starfish_rbc_transaction_payload( + &canonical, + payload, + &committee, + 1, + 0, + &mut verifier, + BlockAuthenticationScheme::MacVector, + &[], + ) + .unwrap(); + assert_eq!(verified.block_reference, canonical.reference()); + assert_eq!(verified.transaction_data.transactions(), &transactions); + assert_eq!(verified.shard_data.shard_index(), 1); + + let tampered = Arc::new(TransactionData::new(vec![BaseTransaction::Share( + Transaction::new(vec![8; 64]), + )])); + assert!( + verify_starfish_rbc_transaction_payload( + &canonical, + tampered, + &committee, + 1, + 0, + &mut verifier, + BlockAuthenticationScheme::MacVector, + &[], + ) + .is_err() + ); + } + + #[test] + fn block_filter_allows_exactly_one_tag_to_full_mac_upgrade() { + let filter = FilterForBlocks::new(); + let digest = BlockReference::new_test(1, 7).digest; + + assert_eq!( + filter.needed_headers(&[(digest, false), (digest, true), (digest, true)]), + vec![true, true, false] + ); + assert_eq!( + filter.insert_and_report_useful(&[(digest, false)]), + vec![true] + ); + assert_eq!(filter.needed_headers(&[(digest, false)]), vec![false]); + assert_eq!(filter.needed_headers(&[(digest, true)]), vec![true]); + assert_eq!( + filter.insert_and_report_useful(&[(digest, true), (digest, true)]), + vec![true, false] + ); + assert_eq!(filter.needed_headers(&[(digest, true)]), vec![false]); + assert_eq!(filter.contains_full_mac_batch(&[digest]), vec![true]); + } + + #[test] + fn full_mac_vectors_require_direct_author_block_streaming() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = crypto::mac_keyrings_for_test(committee.len()); + let mut full = VerifiedBlock::new( + 1, + 1, + Vec::new(), + Vec::new(), + 0, + SignatureBytes::default(), + Vec::new(), + None, + None, + None, + None, + ); + let tags = keyrings[1] + .iter() + .enumerate() + .map(|(recipient, key)| key.compute_tag(1, recipient as AuthorityIndex, &full.digest())) + .collect(); + full.header.authentication = BlockAuthentication::MacVector(tags); + + assert!( + verify_mac_transport( + &full, + BlockAuthenticationScheme::MacVector, + 1, + DataSource::BlockBundleStreaming, + ) + .is_ok() + ); + assert!( + verify_mac_transport( + &full, + BlockAuthenticationScheme::MacVector, + 1, + DataSource::BlockBundleStreamingHeader, + ) + .is_ok() + ); + assert!( + verify_mac_transport( + &full, + BlockAuthenticationScheme::MacVector, + 2, + DataSource::BlockBundleStreaming, + ) + .is_err() + ); + assert!( + verify_mac_transport( + &full, + BlockAuthenticationScheme::MacVector, + 1, + DataSource::BlockHeaderRequest, + ) + .is_err() + ); + + let tagged = full.with_recipient_mac(0).unwrap(); + assert!( + verify_mac_transport( + &tagged, + BlockAuthenticationScheme::MacVector, + 2, + DataSource::BlockBundleStreaming, + ) + .is_ok() + ); + assert!( + verify_mac_transport( + &tagged, + BlockAuthenticationScheme::MacVector, + 1, + DataSource::BlockHeaderRequest, + ) + .is_ok() + ); + assert!( + verify_mac_transport( + &tagged, + BlockAuthenticationScheme::MacVector, + 1, + DataSource::BlockBundleStreaming, + ) + .is_err() + ); + + let round_gap_blocks = prepare_forwarded_blocks_for_peer( + BlockAuthenticationScheme::MacVector, + ConsensusProtocol::Bluestreak, + 0, + vec![Data::new(full)], + ); + assert_eq!(round_gap_blocks.len(), 1); + assert!(matches!( + round_gap_blocks[0].authentication(), + BlockAuthentication::MacTag(_) + )); + assert!( + verify_mac_transport( + &round_gap_blocks[0], + BlockAuthenticationScheme::MacVector, + 2, + DataSource::RoundGapResponse, + ) + .is_ok() + ); + } + #[test] fn acknowledgments_imply_peer_knows_shard_data() { let ack_ref = BlockReference::new_test(2, 3); diff --git a/crates/starfish-core/src/network.rs b/crates/starfish-core/src/network.rs index e62c4426..5ee30976 100644 --- a/crates/starfish-core/src/network.rs +++ b/crates/starfish-core/src/network.rs @@ -28,6 +28,7 @@ use crate::{ metrics::{Metrics, print_network_address_table}, runtime, runtime::JoinHandle, + starfish_rbc::{RbcCanonicalHeader, RbcHeaderProposal, RbcPhase, RbcPhaseMessage}, stat::HistogramSender, types::{ AuthorityIndex, AuthoritySet, BlockReference, CertMessage, CertMessageKind, PartialSig, @@ -178,6 +179,17 @@ pub enum NetworkMessage { round: RoundNumber, known_authorities: AuthoritySet, }, + /// Starfish-RBC: direct-author canonical header and receiver-specific + /// initial proof. + RbcInitial(RbcHeaderProposal), + /// Starfish-RBC: direct, recipient-specific ECHO or READY testimony. + RbcPhase(RbcPhaseMessage), + /// Starfish-RBC: request canonical header content for a phase-evidenced + /// block reference. + RbcHeaderRequest(BlockReference), + /// Starfish-RBC: return canonical header content. The receiver recomputes + /// and checks its content-addressed reference before accepting it. + RbcHeaderResponse(RbcCanonicalHeader), } impl NetworkMessage { @@ -198,6 +210,13 @@ impl NetworkMessage { Self::SailfishNoVote(_) => "sailfish_no_vote", Self::UnprovableCertificateRequest { .. } => "unprovable_cert_request", Self::RoundGapRequest { .. } => "round_gap_request", + Self::RbcInitial(_) => "rbc_initial", + Self::RbcPhase(message) => match message.phase() { + RbcPhase::Echo => "rbc_echo", + RbcPhase::Ready => "rbc_ready", + }, + Self::RbcHeaderRequest(_) => "rbc_header_request", + Self::RbcHeaderResponse(_) => "rbc_header_response", } } } @@ -520,6 +539,7 @@ impl Worker { let start = Instant::now(); let bytes_sent_total = metrics.bytes_sent_total.clone(); let network_requests_sent_total = metrics.network_requests_sent_total.clone(); + let network_message_bytes_sent_total = metrics.network_message_bytes_sent_total.clone(); // Spawn the first task for handling pings let writer_clone = Arc::clone(&writer); @@ -617,12 +637,13 @@ impl Worker { } else { serialized }; + let framed_len = wire_bytes.len() as u64 + 4; match async { let mut writer_guard = writer.lock().await; writer_guard.write_u32(wire_bytes.len() as u32).await?; - bytes_sent_total.inc_by(wire_bytes.len() as u64 + 4); + bytes_sent_total.inc_by(framed_len); writer_guard.write_all(&wire_bytes).await } .await @@ -631,6 +652,9 @@ impl Worker { network_requests_sent_total .with_label_values(&[request_type]) .inc(); + network_message_bytes_sent_total + .with_label_values(&[request_type]) + .inc_by(framed_len); } Err(e) => { tracing::error!("Failed to write message: {e}"); @@ -661,6 +685,7 @@ impl Worker { let writer = writer.clone(); let bytes_sent_total = bytes_sent_total.clone(); let network_requests_sent_total = network_requests_sent_total.clone(); + let network_message_bytes_sent_total = network_message_bytes_sent_total.clone(); let bytes_uncompressed_sent_total = metrics.bytes_uncompressed_sent_total.clone(); let latency = generate_latency(effective_latency(connection_latency, connection_scaled)); @@ -674,13 +699,14 @@ impl Worker { } else { serialized }; + let framed_len = wire_bytes.len() as u64 + 4; tokio::time::sleep(latency).await; match async { let mut writer_guard = writer.lock().await; writer_guard.write_u32(wire_bytes.len() as u32).await?; - bytes_sent_total.inc_by(wire_bytes.len() as u64 + 4); + bytes_sent_total.inc_by(framed_len); writer_guard.write_all(&wire_bytes).await } .await @@ -689,6 +715,9 @@ impl Worker { network_requests_sent_total .with_label_values(&[request_type]) .inc(); + network_message_bytes_sent_total + .with_label_values(&[request_type]) + .inc_by(framed_len); } Err(e) => { tracing::error!("Failed to write message: {e}"); @@ -766,6 +795,10 @@ impl Worker { match deserialize_result { Some(message) => { let request_type = message.request_type(); + metrics + .network_message_bytes_received_total + .with_label_values(&[request_type]) + .inc_by(read as u64 + 4); if sender.send(message).await.is_err() { // todo - pass signal to break main loop return Ok(()); @@ -926,3 +959,66 @@ fn decode_ping(message: &[u8]) -> i64 { m.copy_from_slice(message); // asserts message.len() == 8 i64::from_le_bytes(m) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + crypto::{MacTag, TransactionsCommitment, dummy_signer}, + starfish_rbc::{RbcInitialProof, RbcPhaseMessage}, + }; + + fn variant_index(message: &NetworkMessage) -> u32 { + let bytes = bincode::serialize(message).unwrap(); + u32::from_le_bytes(bytes[..4].try_into().unwrap()) + } + + #[test] + fn rbc_wire_variants_are_append_only_and_roundtrip() { + // This pre-existing last variant is frozen at index 10. Adding RBC + // messages must not renumber any legacy bincode discriminant. + let legacy = NetworkMessage::RoundGapRequest { + round: 7, + known_authorities: AuthoritySet::default(), + }; + assert_eq!(variant_index(&legacy), 10); + + let header = RbcCanonicalHeader::try_new( + 0, + 1, + Vec::new(), + Vec::new(), + 11, + TransactionsCommitment::default(), + ) + .unwrap(); + let block_ref = header.reference(); + let initial = NetworkMessage::RbcInitial(RbcHeaderProposal::new( + header.clone(), + RbcInitialProof::Ed25519(dummy_signer().sign_digest(&[0xA1; 32])), + )); + let phase = NetworkMessage::RbcPhase(RbcPhaseMessage::new_for_test( + block_ref, + 1, + 2, + RbcPhase::Ready, + MacTag::from_bytes([0xA2; 32]), + )); + let request = NetworkMessage::RbcHeaderRequest(block_ref); + let response = NetworkMessage::RbcHeaderResponse(header); + + for (message, expected_index, expected_kind) in [ + (initial, 11, "rbc_initial"), + (phase, 12, "rbc_ready"), + (request, 13, "rbc_header_request"), + (response, 14, "rbc_header_response"), + ] { + assert_eq!(variant_index(&message), expected_index); + assert_eq!(message.request_type(), expected_kind); + let encoded = bincode::serialize(&message).unwrap(); + let decoded: NetworkMessage = bincode::deserialize(&encoded).unwrap(); + assert_eq!(decoded.request_type(), expected_kind); + assert_eq!(variant_index(&decoded), expected_index); + } + } +} diff --git a/crates/starfish-core/src/sailfish_service.rs b/crates/starfish-core/src/sailfish_service.rs index 62fb3d0e..b580fac9 100644 --- a/crates/starfish-core/src/sailfish_service.rs +++ b/crates/starfish-core/src/sailfish_service.rs @@ -551,7 +551,6 @@ mod tests { &[], &[], 1, - &crate::crypto::SignatureBytes::default(), None, None, ), diff --git a/crates/starfish-core/src/starfish_rbc.rs b/crates/starfish-core/src/starfish_rbc.rs new file mode 100644 index 00000000..cc78f5c5 --- /dev/null +++ b/crates/starfish-core/src/starfish_rbc.rs @@ -0,0 +1,4806 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Synchronous reliable-broadcast kernel for Starfish-RBC. +//! +//! Networking and DAG admission remain outside the kernel: the service adapter +//! supplies content-validated headers and expands typed multicast effects into +//! recipient-specific messages. + +use std::{collections::BTreeMap, error::Error, fmt, sync::Arc}; + +use ahash::{AHashMap, AHashSet}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::{ + committee::{Committee, QuorumThreshold, StakeAggregator, ValidityThreshold}, + crypto::{ + Blake3Hasher, MacKey, MacTag, MlDsa44SignatureBytes, MlDsa65SignatureBytes, SignatureBytes, + TransactionsCommitment, + }, + types::{ + AckFields, AuthorityIndex, AuthoritySet, BlockAuthentication, BlockAuthenticationScheme, + BlockDigest, BlockHeader, BlockReference, MAX_COMMITTEE_SIZE, RoundNumber, Stake, + StarfishRbcFieldsV3, StarfishRbcReferenceKindV3, StarfishRbcReferenceV3, TimestampNs, + TransactionData, VerifiedBlock, compress_acknowledgments, expand_acknowledgments, + }, +}; + +const PROTOCOL_DOMAIN: &[u8; 15] = b"STARFISH_RBC_V1"; +const COMMITTEE_ID_DERIVE_CONTEXT: &str = "STARFISH_RBC_V1_COMMITTEE_ID"; +const INITIAL_KIND: u8 = 0x00; +const ECHO_KIND: u8 = 0x01; +const READY_KIND: u8 = 0x02; + +const PROTOCOL_INSTANCE_SIZE: usize = 32; +const COMMITTEE_ID_SIZE: usize = 32; +const BLOCK_REFERENCE_SIZE: usize = 2 + 4 + 32; +const BASE_STATEMENT_SIZE: usize = PROTOCOL_DOMAIN.len() + + 1 + + 1 + + PROTOCOL_INSTANCE_SIZE + + COMMITTEE_ID_SIZE + + BLOCK_REFERENCE_SIZE; +const MAC_STATEMENT_SIZE: usize = BASE_STATEMENT_SIZE + 2 + 2; +const RBC_BLOCK_REFERENCE_SIZE: usize = 2 + 4 + 32; +const RBC_HEADER_FIXED_CONTENT_SIZE: usize = 1 + 2 + 1 + 4 + 1 + 4 + 1 + 4 + 1 + 8 + 1 + 32; +const MAX_RBC_HEADER_CONTENT_SIZE: usize = 4 * 1024 * 1024; +const MAX_RBC_REFERENCES_PER_FIELD: usize = u16::MAX as usize; +const MAX_RBC_FUTURE_ROUNDS: RoundNumber = 100; + +mod bounded_references { + use std::{fmt, marker::PhantomData}; + + use serde::de::{Error as _, SeqAccess, Visitor}; + + use super::*; + + pub(super) fn serialize( + references: &[BlockReference], + serializer: S, + ) -> Result + where + S: Serializer, + { + references.serialize(serializer) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + struct ReferencesVisitor(PhantomData); + + impl<'de> Visitor<'de> for ReferencesVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "at most {MAX_RBC_REFERENCES_PER_FIELD} Starfish-RBC references" + ) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let size_hint = sequence.size_hint().unwrap_or(0); + if size_hint > MAX_RBC_REFERENCES_PER_FIELD { + return Err(A::Error::custom(format!( + "RBC reference count {size_hint} exceeds {MAX_RBC_REFERENCES_PER_FIELD}" + ))); + } + let mut references = Vec::with_capacity(size_hint); + while let Some(reference) = sequence.next_element()? { + if references.len() == MAX_RBC_REFERENCES_PER_FIELD { + return Err(A::Error::custom(format!( + "RBC reference count exceeds {MAX_RBC_REFERENCES_PER_FIELD}" + ))); + } + references.push(reference); + } + Ok(references) + } + } + + deserializer.deserialize_seq(ReferencesVisitor(PhantomData)) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub(crate) struct RbcAckFields { + intersection: Option, + #[serde(with = "bounded_references")] + extra_references: Vec, +} + +impl RbcAckFields { + fn from_logical( + block_references: &[BlockReference], + acknowledgment_references: &[BlockReference], + ) -> Self { + let (intersection, extra_references) = + compress_acknowledgments(block_references, acknowledgment_references); + Self { + intersection, + extra_references, + } + } + + fn logical(&self, block_references: &[BlockReference]) -> Vec { + expand_acknowledgments(block_references, self.intersection, &self.extra_references) + } + + fn is_canonical(&self, block_references: &[BlockReference]) -> bool { + if self + .intersection + .is_some_and(|start| start as usize > block_references.len()) + { + return false; + } + let logical = self.logical(block_references); + let (intersection, extra_references) = compress_acknowledgments(block_references, &logical); + self.intersection == intersection && self.extra_references == extra_references + } +} + +/// Authentication-free, canonical Starfish-RBC header content. +/// +/// Acknowledgments stay canonically compressed on wire, but the digest hashes +/// their expanded logical vector with an explicit boundary from parents. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct RbcCanonicalHeader { + reference: BlockReference, + #[serde(with = "bounded_references")] + block_references: Vec, + acknowledgments: RbcAckFields, + meta_creation_time_ns: TimestampNs, + transactions_commitment: TransactionsCommitment, + #[serde(default)] + starfish_rbc_v3: Option, +} + +impl RbcCanonicalHeader { + pub(crate) fn try_new( + authority: AuthorityIndex, + round: RoundNumber, + block_references: Vec, + acknowledgment_references: Vec, + meta_creation_time_ns: TimestampNs, + transactions_commitment: TransactionsCommitment, + ) -> Result { + Self::try_new_with_fields( + authority, + round, + block_references, + acknowledgment_references, + meta_creation_time_ns, + transactions_commitment, + None, + ) + } + + pub(crate) fn try_new_single_dag( + authority: AuthorityIndex, + round: RoundNumber, + block_references: Vec, + acknowledgment_references: Vec, + meta_creation_time_ns: TimestampNs, + transactions_commitment: TransactionsCommitment, + starfish_rbc_v3: StarfishRbcFieldsV3, + ) -> Result { + Self::try_new_with_fields( + authority, + round, + block_references, + acknowledgment_references, + meta_creation_time_ns, + transactions_commitment, + Some(starfish_rbc_v3), + ) + } + + fn try_new_with_fields( + authority: AuthorityIndex, + round: RoundNumber, + block_references: Vec, + acknowledgment_references: Vec, + meta_creation_time_ns: TimestampNs, + transactions_commitment: TransactionsCommitment, + starfish_rbc_v3: Option, + ) -> Result { + for (field, count) in [ + ("parent", block_references.len()), + ("acknowledgment", acknowledgment_references.len()), + ] { + if count > MAX_RBC_REFERENCES_PER_FIELD { + return Err(RbcError::TooManyHeaderReferences { + field, + count, + maximum: MAX_RBC_REFERENCES_PER_FIELD, + }); + } + } + let mut parent_set = AHashSet::new(); + for parent in &block_references { + if !parent_set.insert(*parent) { + return Err(RbcError::DuplicateParent(*parent)); + } + } + let mut acknowledgment_set = AHashSet::new(); + for acknowledgment in &acknowledgment_references { + if !acknowledgment_set.insert(*acknowledgment) { + return Err(RbcError::DuplicateAcknowledgment(*acknowledgment)); + } + } + + let acknowledgments = + RbcAckFields::from_logical(&block_references, &acknowledgment_references); + let logical_acknowledgments = acknowledgments.logical(&block_references); + let digest = match starfish_rbc_v3.as_ref() { + Some(rbc) => BlockDigest::new_starfish_rbc_single_dag_header( + authority, + round, + &block_references, + &logical_acknowledgments, + meta_creation_time_ns, + transactions_commitment, + rbc, + ), + None => BlockDigest::new_starfish_rbc_header( + authority, + round, + &block_references, + &logical_acknowledgments, + meta_creation_time_ns, + transactions_commitment, + ), + }; + let reference = BlockReference { + authority, + round, + digest, + }; + let header = Self { + reference, + block_references, + acknowledgments, + meta_creation_time_ns, + transactions_commitment, + starfish_rbc_v3, + }; + if header.encoded_content_size(logical_acknowledgments.len())? > MAX_RBC_HEADER_CONTENT_SIZE + { + return Err(RbcError::HeaderContentTooLarge); + } + Ok(header) + } + + pub(crate) fn from_block_header(header: &BlockHeader) -> Result { + if header.strong_vote.is_some() + || header.bls.is_some() + || header.sailfish.is_some() + || header.unprovable_certificate.is_some() + { + return Err(RbcError::ForbiddenHeaderExtensions); + } + let Some(acknowledgments) = header.ack.as_ref() else { + return Err(RbcError::MissingAcknowledgments); + }; + let Some(transactions_commitment) = header.transactions_commitment else { + return Err(RbcError::MissingTransactionsCommitment); + }; + let parent_count = header.block_references.len(); + let extra_acknowledgment_count = acknowledgments.extra_references.len(); + for (field, count) in [ + ("parent", parent_count), + ("acknowledgment", extra_acknowledgment_count), + ] { + if count > MAX_RBC_REFERENCES_PER_FIELD { + return Err(RbcError::TooManyHeaderReferences { + field, + count, + maximum: MAX_RBC_REFERENCES_PER_FIELD, + }); + } + } + let intersection_start = match acknowledgments.intersection { + Some(start) if start as usize <= parent_count => start as usize, + Some(_) => return Err(RbcError::NonCanonicalAcknowledgments), + None => parent_count, + }; + let logical_acknowledgment_count = parent_count + .checked_sub(intersection_start) + .and_then(|count| count.checked_add(extra_acknowledgment_count)) + .ok_or(RbcError::HeaderContentTooLarge)?; + if logical_acknowledgment_count > MAX_RBC_REFERENCES_PER_FIELD { + return Err(RbcError::TooManyHeaderReferences { + field: "acknowledgment", + count: logical_acknowledgment_count, + maximum: MAX_RBC_REFERENCES_PER_FIELD, + }); + } + let reference_count = parent_count + .checked_add(logical_acknowledgment_count) + .and_then(|count| { + count.checked_add( + header + .starfish_rbc_v3 + .as_ref() + .map_or(0, |rbc| rbc.references().len()), + ) + }) + .ok_or(RbcError::HeaderContentTooLarge)?; + let encoded_size = RBC_BLOCK_REFERENCE_SIZE + .checked_mul(reference_count) + .and_then(|size| size.checked_add(RBC_HEADER_FIXED_CONTENT_SIZE)) + .ok_or(RbcError::HeaderContentTooLarge)?; + if encoded_size > MAX_RBC_HEADER_CONTENT_SIZE { + return Err(RbcError::HeaderContentTooLarge); + } + Ok(Self { + reference: header.reference, + block_references: header.block_references.clone(), + acknowledgments: RbcAckFields { + intersection: acknowledgments.intersection, + extra_references: acknowledgments.extra_references.clone(), + }, + meta_creation_time_ns: header.meta_creation_time_ns, + transactions_commitment, + starfish_rbc_v3: header.starfish_rbc_v3.clone(), + }) + } + + pub fn reference(&self) -> BlockReference { + self.reference + } + + pub fn block_references(&self) -> &[BlockReference] { + &self.block_references + } + + pub fn acknowledgment_references(&self) -> Vec { + self.acknowledgments.logical(&self.block_references) + } + + pub(crate) fn acknowledgment_fields(&self) -> AckFields { + AckFields { + intersection: self.acknowledgments.intersection, + extra_references: self.acknowledgments.extra_references.clone(), + } + } + + pub fn meta_creation_time_ns(&self) -> TimestampNs { + self.meta_creation_time_ns + } + + pub fn transactions_commitment(&self) -> TransactionsCommitment { + self.transactions_commitment + } + + pub fn starfish_rbc_v3(&self) -> Option<&StarfishRbcFieldsV3> { + self.starfish_rbc_v3.as_ref() + } + + /// Validate canonical header content against an already validated static + /// committee without deriving the committee identifier. + /// + /// This is the shared structural boundary for RBC INIT/recovery and for a + /// later normal block-batch payload carrier. Global committee invariants + /// are checked once when the RBC context is created; this per-header path + /// touches only authorities referenced by the header. + pub(crate) fn validate_for_committee(&self, committee: &Committee) -> Result<(), RbcError> { + let block_ref = self.reference; + if block_ref.round == 0 { + return Err(RbcError::GenesisSlot); + } + if !committee.known_authority(block_ref.authority) { + return Err(RbcError::UnknownAuthority(block_ref.authority)); + } + if !self.acknowledgments.is_canonical(&self.block_references) { + return Err(RbcError::NonCanonicalAcknowledgments); + } + + let acknowledgments = self.acknowledgment_references(); + for (field, count) in [ + ("parent", self.block_references.len()), + ("acknowledgment", acknowledgments.len()), + ] { + if count > MAX_RBC_REFERENCES_PER_FIELD { + return Err(RbcError::TooManyHeaderReferences { + field, + count, + maximum: MAX_RBC_REFERENCES_PER_FIELD, + }); + } + } + if self.encoded_content_size(acknowledgments.len())? > MAX_RBC_HEADER_CONTENT_SIZE { + return Err(RbcError::HeaderContentTooLarge); + } + + let mut parent_set = AHashSet::new(); + let mut previous_round_parents = StakeAggregator::::new(); + for parent in &self.block_references { + if !committee.known_authority(parent.authority) { + return Err(RbcError::UnknownAuthority(parent.authority)); + } + if parent.round >= block_ref.round { + return Err(RbcError::ParentNotPast(*parent)); + } + if !parent_set.insert(*parent) { + return Err(RbcError::DuplicateParent(*parent)); + } + if parent.round + 1 == block_ref.round { + previous_round_parents.add(parent.authority, committee); + } + } + if !previous_round_parents.is_quorum(committee) { + return Err(RbcError::InvalidThresholdClock); + } + + let mut acknowledgment_set = AHashSet::new(); + for acknowledgment in &acknowledgments { + if !committee.known_authority(acknowledgment.authority) { + return Err(RbcError::UnknownAuthority(acknowledgment.authority)); + } + if acknowledgment.round > block_ref.round { + return Err(RbcError::AcknowledgmentFromFuture(*acknowledgment)); + } + if !acknowledgment_set.insert(*acknowledgment) { + return Err(RbcError::DuplicateAcknowledgment(*acknowledgment)); + } + } + + if self + .starfish_rbc_v3 + .as_ref() + .is_some_and(|rbc| !rbc.validate_for_block(committee, block_ref.round)) + { + return Err(RbcError::InvalidSingleDagEvidence); + } + let expected_digest = match self.starfish_rbc_v3.as_ref() { + Some(rbc) => BlockDigest::new_starfish_rbc_single_dag_header( + block_ref.authority, + block_ref.round, + &self.block_references, + &acknowledgments, + self.meta_creation_time_ns, + self.transactions_commitment, + rbc, + ), + None => BlockDigest::new_starfish_rbc_header( + block_ref.authority, + block_ref.round, + &self.block_references, + &acknowledgments, + self.meta_creation_time_ns, + self.transactions_commitment, + ), + }; + if expected_digest != block_ref.digest { + return Err(RbcError::HeaderDigestMismatch { + expected: expected_digest, + actual: block_ref.digest, + }); + } + Ok(()) + } + + /// Convert canonical content into the existing header-only carrier. RBC + /// authorization remains external, so the compatibility header contains + /// no signature or MAC sidecar. + pub(crate) fn to_authentication_free_block(&self) -> VerifiedBlock { + VerifiedBlock::from_parts( + BlockHeader { + reference: self.reference, + block_references: self.block_references.clone(), + meta_creation_time_ns: self.meta_creation_time_ns, + authentication: BlockAuthentication::None, + transactions_commitment: Some(self.transactions_commitment), + ack: Some(self.acknowledgment_fields()), + strong_vote: None, + bls: None, + sailfish: None, + unprovable_certificate: None, + starfish_rbc_v3: self.starfish_rbc_v3.clone(), + serialized: None, + }, + None, + ) + } + + fn encoded_content_size(&self, acknowledgment_count: usize) -> Result { + let reference_count = self + .block_references + .len() + .checked_add(acknowledgment_count) + .and_then(|count| { + count.checked_add( + self.starfish_rbc_v3 + .as_ref() + .map_or(0, |rbc| rbc.references().len()), + ) + }) + .ok_or(RbcError::HeaderContentTooLarge)?; + RBC_BLOCK_REFERENCE_SIZE + .checked_mul(reference_count) + .and_then(|size| size.checked_add(RBC_HEADER_FIXED_CONTENT_SIZE)) + .ok_or(RbcError::HeaderContentTooLarge) + } +} + +/// An intrinsically validated header retained by `Arc` for as long as the RBC +/// state may advertise this validator as a holder. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PinnedRbcHeader { + header: Arc, + committee_id: RbcCommitteeId, +} + +impl PinnedRbcHeader { + fn validate_with_committee_id( + header: RbcCanonicalHeader, + committee: &Committee, + committee_id: RbcCommitteeId, + ) -> Result { + header.validate_for_committee(committee)?; + Ok(Self { + header: Arc::new(header), + committee_id, + }) + } + + #[cfg(test)] + fn validate(header: RbcCanonicalHeader, committee: &Committee) -> Result { + validate_committee(committee)?; + let committee_id = RbcCommitteeId::derive(committee)?; + Self::validate_with_committee_id(header, committee, committee_id) + } + + pub(crate) fn reference(&self) -> BlockReference { + self.header.reference + } + + pub(crate) fn header(&self) -> &RbcCanonicalHeader { + &self.header + } + + fn ensure_committee(&self, expected: RbcCommitteeId) -> Result<(), RbcError> { + if self.committee_id != expected { + return Err(RbcError::PinnedHeaderCommitteeMismatch); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum RbcInitialProof { + Ed25519(SignatureBytes), + MlDsa44(MlDsa44SignatureBytes), + MlDsa65(MlDsa65SignatureBytes), + Mac(MacTag), +} + +/// Direct-author Starfish-RBC header proposal carried on the wire. +/// +/// The proof is a sidecar over the canonical header reference. It is not part +/// of the content-addressed header identity. +#[derive(Clone, Serialize, Deserialize)] +pub struct RbcHeaderProposal { + header: RbcCanonicalHeader, + proof: RbcInitialProof, + transaction_data: Option>, +} + +impl RbcHeaderProposal { + #[cfg(test)] + pub(crate) fn new(header: RbcCanonicalHeader, proof: RbcInitialProof) -> Self { + Self { + header, + proof, + transaction_data: None, + } + } + + pub(crate) fn with_transaction_data( + header: RbcCanonicalHeader, + proof: RbcInitialProof, + transaction_data: Option>, + ) -> Self { + Self { + header, + proof, + transaction_data, + } + } + + pub fn header(&self) -> &RbcCanonicalHeader { + &self.header + } + + pub fn proof(&self) -> &RbcInitialProof { + &self.proof + } + + #[cfg(test)] + pub(crate) fn transaction_data(&self) -> Option<&TransactionData> { + self.transaction_data.as_deref() + } + + pub(crate) fn into_parts( + self, + ) -> ( + RbcCanonicalHeader, + RbcInitialProof, + Option>, + ) { + (self.header, self.proof, self.transaction_data) + } +} + +impl fmt::Debug for RbcHeaderProposal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RbcHeaderProposal") + .field("header", &self.header) + .field("proof", &self.proof) + .field( + "transaction_count", + &self + .transaction_data + .as_ref() + .map(|data| data.number_transactions()), + ) + .finish() + } +} + +impl RbcInitialProof { + #[allow(dead_code)] + pub(crate) fn from_block_authentication( + authentication: &BlockAuthentication, + ) -> Result { + match authentication { + BlockAuthentication::Ed25519(signature) => Ok(Self::Ed25519(*signature)), + BlockAuthentication::MlDsa44(signature) => Ok(Self::MlDsa44(signature.clone())), + BlockAuthentication::MlDsa65(signature) => Ok(Self::MlDsa65(signature.clone())), + BlockAuthentication::MacTag(tag) => Ok(Self::Mac(*tag)), + BlockAuthentication::None | BlockAuthentication::MacVector(_) => { + Err(RbcError::InvalidInitialProof) + } + } + } +} + +/// Capability proving that the pinned header had a valid local-construction +/// path or a direct-author initial proof. Only this type can authorize ECHO. +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct EchoEligibleHeader { + header: PinnedRbcHeader, + context: RbcContext, + recipient: AuthorityIndex, +} + +#[derive(Debug)] +#[must_use] +pub(crate) struct RbcLocalInitial { + header: PinnedRbcHeader, + effects: Vec, + context: RbcContext, + author: AuthorityIndex, +} + +impl RbcLocalInitial { + pub(crate) fn header(&self) -> &RbcCanonicalHeader { + self.header.header() + } + + pub(crate) fn into_parts(self) -> (PinnedRbcHeader, Vec) { + (self.header, self.effects) + } +} + +#[derive(Debug)] +#[must_use] +pub(crate) enum RbcInitialHeaderOutcome { + Authenticated { + effects: Vec, + }, + StagedUnauthenticated { + effects: Vec, + error: RbcError, + }, +} + +#[derive(Clone, Copy, Eq, Hash, PartialEq, Serialize)] +pub(crate) struct RbcProtocolInstanceId([u8; PROTOCOL_INSTANCE_SIZE]); + +impl RbcProtocolInstanceId { + pub(crate) fn new(bytes: [u8; PROTOCOL_INSTANCE_SIZE]) -> Result { + if bytes.iter().all(|byte| *byte == 0) { + return Err(RbcError::ZeroProtocolInstance); + } + Ok(Self(bytes)) + } +} + +impl<'de> Deserialize<'de> for RbcProtocolInstanceId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let bytes = <[u8; PROTOCOL_INSTANCE_SIZE]>::deserialize(deserializer)?; + Self::new(bytes).map_err(de::Error::custom) + } +} + +impl fmt::Debug for RbcProtocolInstanceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RbcInstance({})", hex::encode(&self.0[..4])) + } +} + +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub(crate) struct RbcCommitteeId([u8; COMMITTEE_ID_SIZE]); + +impl RbcCommitteeId { + fn derive(committee: &Committee) -> Result { + if committee.len() > MAX_COMMITTEE_SIZE as usize { + return Err(RbcError::CommitteeTooLarge(committee.len())); + } + let committee_size = u16::try_from(committee.len()) + .map_err(|_| RbcError::CommitteeTooLarge(committee.len()))?; + let info_length = u16::try_from(committee.info_length()) + .map_err(|_| RbcError::InvalidInfoLength(committee.info_length()))?; + + let mut hasher = Blake3Hasher::new_derive_key(COMMITTEE_ID_DERIVE_CONTEXT); + hasher.update(&committee_size.to_be_bytes()); + hasher.update(&committee.validity_threshold().to_be_bytes()); + hasher.update(&committee.quorum_threshold().to_be_bytes()); + hasher.update(&info_length.to_be_bytes()); + hasher.update(&committee.optimistic_fast_threshold().to_be_bytes()); + hasher.update(&committee.optimistic_vote_threshold().to_be_bytes()); + hasher.update(&committee.optimistic_ready_threshold().to_be_bytes()); + + for authority in committee.authorities() { + let stake = committee + .get_stake(authority) + .ok_or(RbcError::UnknownAuthority(authority))?; + let public_key = committee + .get_public_key(authority) + .ok_or(RbcError::UnknownAuthority(authority))?; + let bls_public_key = committee + .get_bls_public_key(authority) + .ok_or(RbcError::UnknownAuthority(authority))?; + let ml_dsa_44_public_key = committee + .get_ml_dsa_44_public_key(authority) + .ok_or(RbcError::UnknownAuthority(authority))?; + let ml_dsa_65_public_key = committee + .get_ml_dsa_65_public_key(authority) + .ok_or(RbcError::UnknownAuthority(authority))?; + + hasher.update(&authority.to_be_bytes()); + hasher.update(&stake.to_be_bytes()); + hasher.update(&public_key.to_bytes()); + hasher.update(&bls_public_key.to_bytes()); + hasher.update(&ml_dsa_44_public_key.to_bytes()); + hasher.update(&ml_dsa_65_public_key.to_bytes()); + } + + Ok(Self(hasher.finalize().into())) + } +} + +impl fmt::Debug for RbcCommitteeId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RbcCommittee({})", hex::encode(&self.0[..4])) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct RbcContext { + protocol_instance: RbcProtocolInstanceId, + committee_id: RbcCommitteeId, + initial_authentication: BlockAuthenticationScheme, +} + +impl RbcContext { + fn new( + protocol_instance: RbcProtocolInstanceId, + committee: &Committee, + initial_authentication: BlockAuthenticationScheme, + ) -> Result { + validate_committee(committee)?; + Ok(Self { + protocol_instance, + committee_id: RbcCommitteeId::derive(committee)?, + initial_authentication, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub enum RbcPhase { + Echo, + Ready, +} + +impl RbcPhase { + fn statement_kind(self) -> u8 { + match self { + Self::Echo => ECHO_KIND, + Self::Ready => READY_KIND, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct RbcPhaseMessage { + block_ref: BlockReference, + sender: AuthorityIndex, + recipient: AuthorityIndex, + phase: RbcPhase, + tag: MacTag, +} + +impl RbcPhaseMessage { + #[cfg(test)] + pub(crate) fn new_for_test( + block_ref: BlockReference, + sender: AuthorityIndex, + recipient: AuthorityIndex, + phase: RbcPhase, + tag: MacTag, + ) -> Self { + Self { + block_ref, + sender, + recipient, + phase, + tag, + } + } + + pub fn block_ref(&self) -> BlockReference { + self.block_ref + } + + pub fn sender(&self) -> AuthorityIndex { + self.sender + } + + pub fn recipient(&self) -> AuthorityIndex { + self.recipient + } + + pub fn phase(&self) -> RbcPhase { + self.phase + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum RbcEffect { + /// The network adapter must specialize this intent for each recipient by + /// calling `make_phase_message`; no tagged message may be cloned. + MulticastPhase { + phase: RbcPhase, + block_ref: BlockReference, + }, + NeedHeader { + block_ref: BlockReference, + holders: AuthoritySet, + }, + Deliver(PinnedRbcHeader), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum RbcError { + EmptyCommittee, + CommitteeTooLarge(usize), + InvalidInfoLength(usize), + InvalidCommitteeStake(AuthorityIndex), + TotalStakeOverflow, + InvalidValidityThreshold { + expected: Stake, + actual: Stake, + }, + InvalidQuorumThreshold { + expected: Stake, + actual: Stake, + }, + InvalidDerivedInfoLength { + expected: usize, + actual: usize, + }, + ZeroProtocolInstance, + InvalidKeyringLength { + expected: usize, + actual: usize, + }, + UnknownAuthority(AuthorityIndex), + GenesisSlot, + FutureRound { + round: RoundNumber, + maximum: RoundNumber, + }, + StaleRound { + round: RoundNumber, + minimum: RoundNumber, + }, + RoundRegression { + current: RoundNumber, + proposed: RoundNumber, + }, + #[allow(dead_code)] + RetainedRoundRegression { + current: RoundNumber, + proposed: RoundNumber, + }, + #[allow(dead_code)] + RetainedRoundAheadOfLocal { + local: RoundNumber, + proposed: RoundNumber, + }, + MissingAcknowledgments, + NonCanonicalAcknowledgments, + MissingTransactionsCommitment, + ForbiddenHeaderExtensions, + TooManyHeaderReferences { + field: &'static str, + count: usize, + maximum: usize, + }, + HeaderContentTooLarge, + ParentNotPast(BlockReference), + DuplicateParent(BlockReference), + AcknowledgmentFromFuture(BlockReference), + DuplicateAcknowledgment(BlockReference), + InvalidThresholdClock, + InvalidSingleDagEvidence, + HeaderDigestMismatch { + expected: BlockDigest, + actual: BlockDigest, + }, + PinnedHeaderCommitteeMismatch, + EchoCapabilityContextMismatch, + LocalInitialContextMismatch, + LocalInitialNotSelected(BlockReference), + ConflictingHeaderContent(BlockReference), + ConflictingInitialHeader { + existing: BlockReference, + received: BlockReference, + }, + UnexpectedRecoveredHeader(BlockReference), + HeaderUnavailable(BlockReference), + WrongRecipient { + expected: AuthorityIndex, + actual: AuthorityIndex, + }, + SenderPeerMismatch { + sender: AuthorityIndex, + peer: AuthorityIndex, + }, + PhaseNotAuthorized { + phase: RbcPhase, + block_ref: BlockReference, + }, + LoopbackPhase, + InvalidPhaseTag, + InvalidInitialTag, + InvalidInitialProof, + InitialProofSchemeMismatch, + InitialSignatureRequiresSignatureAuthentication, + InitialMacRequiresMacAuthentication, + InitialAuthorMismatch { + expected: AuthorityIndex, + actual: AuthorityIndex, + }, +} + +impl fmt::Display for RbcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyCommittee => f.write_str("Starfish-RBC committee is empty"), + Self::CommitteeTooLarge(size) => { + write!(f, "Starfish-RBC committee is too large: {size}") + } + Self::InvalidInfoLength(length) => { + write!( + f, + "Starfish-RBC information length is out of range: {length}" + ) + } + Self::InvalidCommitteeStake(authority) => { + write!(f, "Starfish-RBC authority {authority} has invalid stake") + } + Self::TotalStakeOverflow => f.write_str("Starfish-RBC total stake overflow"), + Self::InvalidValidityThreshold { expected, actual } => write!( + f, + "Starfish-RBC validity threshold mismatch: expected {expected}, got {actual}" + ), + Self::InvalidQuorumThreshold { expected, actual } => write!( + f, + "Starfish-RBC quorum threshold mismatch: expected {expected}, got {actual}" + ), + Self::InvalidDerivedInfoLength { expected, actual } => write!( + f, + "Starfish-RBC information length mismatch: expected {expected}, got {actual}" + ), + Self::ZeroProtocolInstance => { + f.write_str("Starfish-RBC protocol instance must not be all zeroes") + } + Self::InvalidKeyringLength { expected, actual } => write!( + f, + "Starfish-RBC keyring length mismatch: expected {expected}, got {actual}" + ), + Self::UnknownAuthority(authority) => { + write!(f, "unknown Starfish-RBC authority {authority}") + } + Self::GenesisSlot => f.write_str("Starfish-RBC does not certify genesis slots"), + Self::FutureRound { round, maximum } => write!( + f, + "Starfish-RBC round {round} exceeds admission maximum {maximum}" + ), + Self::StaleRound { round, minimum } => write!( + f, + "Starfish-RBC round {round} is below admission minimum {minimum}" + ), + Self::RoundRegression { current, proposed } => write!( + f, + "Starfish-RBC local round cannot regress from {current} to {proposed}" + ), + Self::RetainedRoundRegression { current, proposed } => write!( + f, + "Starfish-RBC retained-round floor cannot regress from {current} to {proposed}" + ), + Self::RetainedRoundAheadOfLocal { local, proposed } => write!( + f, + "Starfish-RBC retained-round floor {proposed} exceeds local round {local}" + ), + Self::MissingAcknowledgments => { + f.write_str("Starfish-RBC header is missing acknowledgment fields") + } + Self::NonCanonicalAcknowledgments => { + f.write_str("Starfish-RBC acknowledgment encoding is not canonical") + } + Self::MissingTransactionsCommitment => { + f.write_str("Starfish-RBC header is missing its transaction commitment") + } + Self::ForbiddenHeaderExtensions => { + f.write_str("Starfish-RBC header carries a forbidden protocol extension") + } + Self::TooManyHeaderReferences { + field, + count, + maximum, + } => write!( + f, + "Starfish-RBC {field} reference count {count} exceeds limit {maximum}" + ), + Self::HeaderContentTooLarge => f.write_str("Starfish-RBC header content is too large"), + Self::ParentNotPast(parent) => { + write!(f, "Starfish-RBC parent {parent} is not from a past round") + } + Self::DuplicateParent(parent) => { + write!(f, "Starfish-RBC parent {parent} is duplicated") + } + Self::AcknowledgmentFromFuture(acknowledgment) => write!( + f, + "Starfish-RBC acknowledgment {acknowledgment} is from a future round" + ), + Self::DuplicateAcknowledgment(acknowledgment) => write!( + f, + "Starfish-RBC acknowledgment {acknowledgment} is duplicated" + ), + Self::InvalidThresholdClock => { + f.write_str("Starfish-RBC header does not reference previous-round quorum stake") + } + Self::InvalidSingleDagEvidence => { + f.write_str("Starfish-RBC V3 block carries non-canonical reference evidence") + } + Self::HeaderDigestMismatch { expected, actual } => write!( + f, + "Starfish-RBC header digest mismatch: expected {expected}, got {actual}" + ), + Self::PinnedHeaderCommitteeMismatch => { + f.write_str("Starfish-RBC pinned header belongs to a different committee") + } + Self::EchoCapabilityContextMismatch => f.write_str( + "Starfish-RBC ECHO capability belongs to a different context or recipient", + ), + Self::LocalInitialContextMismatch => { + f.write_str("Starfish-RBC local initial handle belongs to a different kernel") + } + Self::LocalInitialNotSelected(block_ref) => write!( + f, + "Starfish-RBC local initial header {block_ref} is not the selected pinned proposal" + ), + Self::ConflictingHeaderContent(block_ref) => write!( + f, + "Starfish-RBC received conflicting pinned content for {block_ref}" + ), + Self::ConflictingInitialHeader { existing, received } => write!( + f, + "Starfish-RBC slot already staged initial header {existing}, not {received}" + ), + Self::UnexpectedRecoveredHeader(block_ref) => write!( + f, + "Starfish-RBC recovered header {block_ref} has no retained candidate" + ), + Self::HeaderUnavailable(block_ref) => { + write!( + f, + "Starfish-RBC header {block_ref} is not locally available" + ) + } + Self::WrongRecipient { expected, actual } => write!( + f, + "Starfish-RBC message recipient mismatch: expected {expected}, got {actual}" + ), + Self::SenderPeerMismatch { sender, peer } => write!( + f, + "Starfish-RBC message sender {sender} does not match direct peer {peer}" + ), + Self::PhaseNotAuthorized { phase, block_ref } => write!( + f, + "Starfish-RBC {phase:?} was not authorized for {block_ref}" + ), + Self::LoopbackPhase => { + f.write_str("Starfish-RBC loopback phase messages are not accepted") + } + Self::InvalidPhaseTag => f.write_str("Starfish-RBC phase MAC verification failed"), + Self::InvalidInitialTag => f.write_str("Starfish-RBC initial MAC verification failed"), + Self::InvalidInitialProof => { + f.write_str("Starfish-RBC initial proof verification failed") + } + Self::InitialProofSchemeMismatch => { + f.write_str("Starfish-RBC initial proof has the wrong authentication scheme") + } + Self::InitialSignatureRequiresSignatureAuthentication => f.write_str( + "Starfish-RBC initial signature digest requires a signature authentication mode", + ), + Self::InitialMacRequiresMacAuthentication => { + f.write_str("Starfish-RBC initial MAC requires MAC authentication mode") + } + Self::InitialAuthorMismatch { expected, actual } => write!( + f, + "Starfish-RBC initial author mismatch: expected {expected}, got {actual}" + ), + } + } +} + +impl Error for RbcError {} + +struct CandidateState { + header: Option, + echoes: StakeAggregator, + readies: StakeAggregator, + echo_quorum_observed: bool, + ready_validity_observed: bool, + ready_quorum_observed: bool, + header_request_holders: AuthoritySet, +} + +impl CandidateState { + fn new() -> Self { + Self { + header: None, + echoes: StakeAggregator::new(), + readies: StakeAggregator::new(), + echo_quorum_observed: false, + ready_validity_observed: false, + ready_quorum_observed: false, + header_request_holders: AuthoritySet::default(), + } + } + + fn latch_thresholds(&mut self, validity_threshold: Stake, quorum_threshold: Stake) { + self.echo_quorum_observed |= self.echoes.get_stake() >= quorum_threshold; + self.ready_validity_observed |= self.readies.get_stake() >= validity_threshold; + self.ready_quorum_observed |= self.readies.get_stake() >= quorum_threshold; + } + + fn holders(&self) -> AuthoritySet { + self.echoes.votes | self.readies.votes + } +} + +struct SlotState { + echoed: Option, + readied: Option, + delivered: Option, + initial_candidate: Option, + echo_by_sender: AHashMap, + ready_by_sender: AHashMap, + candidates: AHashMap, +} + +impl Default for SlotState { + fn default() -> Self { + Self { + echoed: None, + readied: None, + delivered: None, + initial_candidate: None, + echo_by_sender: AHashMap::new(), + ready_by_sender: AHashMap::new(), + candidates: AHashMap::new(), + } + } +} + +impl SlotState { + fn record_phase_sender( + &mut self, + phase: RbcPhase, + sender: AuthorityIndex, + block_ref: BlockReference, + ) -> bool { + let seen = match phase { + RbcPhase::Echo => &mut self.echo_by_sender, + RbcPhase::Ready => &mut self.ready_by_sender, + }; + match seen.get(&sender) { + Some(existing) => *existing == block_ref, + None => { + seen.insert(sender, block_ref); + true + } + } + } +} + +enum ProgressAction { + NeedHeader(AuthoritySet), + SendReady, + Deliver, + None, +} + +pub(crate) struct StarfishRbcKernel { + committee: Arc, + own_authority: AuthorityIndex, + context: RbcContext, + mac_keys: Arc>, + local_round: RoundNumber, + minimum_new_slot_round: RoundNumber, + /// Testbed-only optimistic path. A quorum of locked ECHOs proves a unique + /// value, but without a portable proof it does not prove that every honest + /// node can assemble the same quorum under selective Byzantine + /// withholding. Keep disabled for the asynchronous RBC contract. + echo_qc_fast_path: bool, + slots: BTreeMap>, +} + +impl StarfishRbcKernel { + #[allow(dead_code)] + pub(crate) fn new( + committee: Arc, + own_authority: AuthorityIndex, + protocol_instance: RbcProtocolInstanceId, + initial_authentication: BlockAuthenticationScheme, + mac_keys: Arc>, + local_round: RoundNumber, + ) -> Result { + Self::new_with_echo_qc_fast_path( + committee, + own_authority, + protocol_instance, + initial_authentication, + mac_keys, + local_round, + false, + ) + } + + pub(crate) fn new_with_echo_qc_fast_path( + committee: Arc, + own_authority: AuthorityIndex, + protocol_instance: RbcProtocolInstanceId, + initial_authentication: BlockAuthenticationScheme, + mac_keys: Arc>, + local_round: RoundNumber, + echo_qc_fast_path: bool, + ) -> Result { + let context = RbcContext::new(protocol_instance, &committee, initial_authentication)?; + if !committee.known_authority(own_authority) { + return Err(RbcError::UnknownAuthority(own_authority)); + } + if mac_keys.len() != committee.len() { + return Err(RbcError::InvalidKeyringLength { + expected: committee.len(), + actual: mac_keys.len(), + }); + } + Ok(Self { + committee, + own_authority, + context, + mac_keys, + local_round, + minimum_new_slot_round: 1, + echo_qc_fast_path, + slots: BTreeMap::new(), + }) + } + + #[allow(dead_code)] + pub(crate) fn context(&self) -> RbcContext { + self.context + } + + pub(crate) fn maximum_admissible_round(&self) -> RoundNumber { + self.local_round.saturating_add(MAX_RBC_FUTURE_ROUNDS) + } + + pub(crate) fn advance_local_round(&mut self, round: RoundNumber) -> Result<(), RbcError> { + if round < self.local_round { + return Err(RbcError::RoundRegression { + current: self.local_round, + proposed: round, + }); + } + self.local_round = round; + Ok(()) + } + + #[allow(dead_code)] + pub(crate) fn minimum_new_slot_round(&self) -> RoundNumber { + self.minimum_new_slot_round + } + + /// Reject allocation of previously unseen slots below a monotonic safe + /// watermark. Advancing the DAG round is not sufficient evidence for this + /// call: the integration layer may advance it only when its recovery model + /// proves that no newly observed slot below `round` is still required. + /// Existing slots remain active so late evidence can complete totality. + #[allow(dead_code)] + pub(crate) fn close_new_slots_before(&mut self, round: RoundNumber) -> Result<(), RbcError> { + if round < self.minimum_new_slot_round { + return Err(RbcError::RetainedRoundRegression { + current: self.minimum_new_slot_round, + proposed: round, + }); + } + let local_boundary = RoundNumber::max(self.local_round, 1); + if round > local_boundary { + return Err(RbcError::RetainedRoundAheadOfLocal { + local: self.local_round, + proposed: round, + }); + } + self.minimum_new_slot_round = round; + Ok(()) + } + + pub(crate) fn validate_header_content( + &self, + header: RbcCanonicalHeader, + ) -> Result { + self.validate_block_ref(&header.reference())?; + PinnedRbcHeader::validate_with_committee_id( + header, + &self.committee, + self.context.committee_id, + ) + } + + fn direct_initial_header( + &self, + direct_peer: AuthorityIndex, + header: PinnedRbcHeader, + proof: &RbcInitialProof, + ) -> Result { + header.ensure_committee(self.context.committee_id)?; + let block_ref = header.reference(); + self.validate_block_ref(&block_ref)?; + if direct_peer != block_ref.authority { + return Err(RbcError::InitialAuthorMismatch { + expected: block_ref.authority, + actual: direct_peer, + }); + } + if direct_peer == self.own_authority { + return Err(RbcError::LoopbackPhase); + } + + match (self.context.initial_authentication, proof) { + (BlockAuthenticationScheme::Ed25519, RbcInitialProof::Ed25519(signature)) => { + let digest = self.initial_signature_digest(block_ref)?; + let public_key = self + .committee + .get_public_key(block_ref.authority) + .ok_or(RbcError::UnknownAuthority(block_ref.authority))?; + public_key + .verify_digest_signature(&digest, signature) + .map_err(|_| RbcError::InvalidInitialProof)?; + } + (BlockAuthenticationScheme::MlDsa44, RbcInitialProof::MlDsa44(signature)) => { + let digest = BlockDigest::from(self.initial_signature_digest(block_ref)?); + let public_key = self + .committee + .get_ml_dsa_44_public_key(block_ref.authority) + .ok_or(RbcError::UnknownAuthority(block_ref.authority))?; + public_key + .verify_digest_signature(&digest, signature) + .map_err(|_| RbcError::InvalidInitialProof)?; + } + (BlockAuthenticationScheme::MlDsa65, RbcInitialProof::MlDsa65(signature)) => { + let digest = BlockDigest::from(self.initial_signature_digest(block_ref)?); + let public_key = self + .committee + .get_ml_dsa_65_public_key(block_ref.authority) + .ok_or(RbcError::UnknownAuthority(block_ref.authority))?; + public_key + .verify_digest_signature(&digest, signature) + .map_err(|_| RbcError::InvalidInitialProof)?; + } + (BlockAuthenticationScheme::MacVector, RbcInitialProof::Mac(tag)) => { + self.verify_initial_mac_tag(direct_peer, block_ref, tag)?; + } + _ => return Err(RbcError::InitialProofSchemeMismatch), + } + Ok(EchoEligibleHeader { + header, + context: self.context, + recipient: self.own_authority, + }) + } + + fn local_initial_header( + &self, + header: PinnedRbcHeader, + ) -> Result { + header.ensure_committee(self.context.committee_id)?; + let block_ref = header.reference(); + self.validate_block_ref(&block_ref)?; + if block_ref.authority != self.own_authority { + return Err(RbcError::InitialAuthorMismatch { + expected: self.own_authority, + actual: block_ref.authority, + }); + } + Ok(EchoEligibleHeader { + header, + context: self.context, + recipient: self.own_authority, + }) + } + + /// Atomically construct, validate, select, pin, and ECHO a local-author + /// proposal before exposing it for authentication or dissemination. The + /// caller supplies no author or digest and cannot obtain two conflicting + /// local handles for one slot. + pub(crate) fn start_local_initial_header( + &mut self, + round: RoundNumber, + block_references: Vec, + acknowledgment_references: Vec, + meta_creation_time_ns: TimestampNs, + transactions_commitment: TransactionsCommitment, + ) -> Result { + self.start_local_initial_header_with_fields( + round, + block_references, + acknowledgment_references, + meta_creation_time_ns, + transactions_commitment, + None, + ) + } + + pub(crate) fn start_local_initial_header_with_fields( + &mut self, + round: RoundNumber, + block_references: Vec, + acknowledgment_references: Vec, + meta_creation_time_ns: TimestampNs, + transactions_commitment: TransactionsCommitment, + starfish_rbc_v3: Option, + ) -> Result { + let canonical = match starfish_rbc_v3 { + Some(rbc) => RbcCanonicalHeader::try_new_single_dag( + self.own_authority, + round, + block_references, + acknowledgment_references, + meta_creation_time_ns, + transactions_commitment, + rbc, + ), + None => RbcCanonicalHeader::try_new( + self.own_authority, + round, + block_references, + acknowledgment_references, + meta_creation_time_ns, + transactions_commitment, + ), + }?; + let pinned = self.validate_header_content(canonical)?; + let eligible = self.local_initial_header(pinned.clone())?; + let effects = self.accept_initial_header(eligible)?; + Ok(RbcLocalInitial { + header: pinned, + effects, + context: self.context, + author: self.own_authority, + }) + } + + fn accept_initial_header( + &mut self, + eligible: EchoEligibleHeader, + ) -> Result, RbcError> { + if eligible.context != self.context || eligible.recipient != self.own_authority { + return Err(RbcError::EchoCapabilityContextMismatch); + } + eligible + .header + .ensure_committee(self.context.committee_id)?; + let block_ref = eligible.header.reference(); + self.record_initial_candidate(block_ref)?; + let mut effects = self.note_header_available(eligible.header)?; + effects.extend(self.authorize_echo(block_ref)?); + Ok(effects) + } + + /// Validate and stage a directly received proposal before checking its + /// receiver-specific proof. Invalid authentication therefore cannot make + /// the adapter accidentally discard content needed by later READY + /// recovery. The outcome preserves any effects unblocked by staging. + pub(crate) fn accept_direct_initial_header( + &mut self, + direct_peer: AuthorityIndex, + header: RbcCanonicalHeader, + proof: &RbcInitialProof, + ) -> Result { + let pinned = self.validate_header_content(header)?; + let block_ref = pinned.reference(); + if direct_peer != block_ref.authority { + return Err(RbcError::InitialAuthorMismatch { + expected: block_ref.authority, + actual: direct_peer, + }); + } + if direct_peer == self.own_authority { + return Err(RbcError::LoopbackPhase); + } + self.record_initial_candidate(block_ref)?; + let mut effects = self.note_header_available(pinned.clone())?; + match self.direct_initial_header(direct_peer, pinned, proof) { + Ok(eligible) => { + effects.extend(self.accept_initial_header(eligible)?); + Ok(RbcInitialHeaderOutcome::Authenticated { effects }) + } + Err(error) => Ok(RbcInitialHeaderOutcome::StagedUnauthenticated { effects, error }), + } + } + + pub(crate) fn accept_recovered_header( + &mut self, + header: RbcCanonicalHeader, + ) -> Result, RbcError> { + let pinned = self.validate_header_content(header)?; + let block_ref = pinned.reference(); + self.validate_block_ref(&block_ref)?; + if self.candidate(&block_ref).is_none() { + return Err(RbcError::UnexpectedRecoveredHeader(block_ref)); + } + self.note_header_available(pinned) + } + + /// Record a pinned, deterministically content-validated header. This does + /// not authorize ECHO and does not imply initial authentication. External + /// ingress uses `accept_recovered_header` or an echo-eligible capability. + fn note_header_available( + &mut self, + header: PinnedRbcHeader, + ) -> Result, RbcError> { + header.ensure_committee(self.context.committee_id)?; + let block_ref = header.reference(); + self.validate_block_ref(&block_ref)?; + let candidate = self.candidate_mut(block_ref); + if candidate + .header + .as_ref() + .is_some_and(|existing| existing != &header) + { + return Err(RbcError::ConflictingHeaderContent(block_ref)); + } + candidate.header = Some(header); + Ok(self.drive(block_ref)) + } + + /// Complete the one local ECHO transition after the typed capability gate. + /// This lower-level method remains module-private so call ordering cannot + /// substitute for a direct-author proof or local-creation capability. + fn authorize_echo(&mut self, block_ref: BlockReference) -> Result, RbcError> { + self.validate_block_ref(&block_ref)?; + let header_available = self + .candidate(&block_ref) + .is_some_and(|candidate| candidate.header.is_some()); + if !header_available { + return Err(RbcError::HeaderUnavailable(block_ref)); + } + + let own_authority = self.own_authority; + let committee = Arc::clone(&self.committee); + let slot = self.slot_mut(block_ref); + if slot.echoed.is_some() { + return Ok(Vec::new()); + } + slot.echoed = Some(block_ref); + let recorded = slot.record_phase_sender(RbcPhase::Echo, own_authority, block_ref); + debug_assert!(recorded, "local ECHO guard and sender record diverged"); + slot.candidates + .entry(block_ref) + .or_insert_with(CandidateState::new) + .echoes + .add(own_authority, &committee); + + let mut effects = vec![RbcEffect::MulticastPhase { + phase: RbcPhase::Echo, + block_ref, + }]; + effects.extend(self.drive(block_ref)); + Ok(effects) + } + + pub(crate) fn handle_phase( + &mut self, + direct_peer: AuthorityIndex, + message: RbcPhaseMessage, + ) -> Result, RbcError> { + self.verify_phase_message(direct_peer, &message)?; + let committee = Arc::clone(&self.committee); + let slot = self.slot_mut(message.block_ref); + if !slot.record_phase_sender(message.phase, message.sender, message.block_ref) { + return Ok(Vec::new()); + } + let candidate = slot + .candidates + .entry(message.block_ref) + .or_insert_with(CandidateState::new); + match message.phase { + RbcPhase::Echo => { + candidate.echoes.add(message.sender, &committee); + } + RbcPhase::Ready => { + candidate.readies.add(message.sender, &committee); + } + } + Ok(self.drive(message.block_ref)) + } + + /// Apply a statement authenticated by the ordinary V3 block that carries + /// it. The carrying block author is the RBC sender; no standalone phase + /// MAC or second network message exists in this path. + pub(crate) fn handle_embedded_reference( + &mut self, + authenticated_sender: AuthorityIndex, + evidence: StarfishRbcReferenceV3, + ) -> Result, RbcError> { + if !self.committee.known_authority(authenticated_sender) { + return Err(RbcError::UnknownAuthority(authenticated_sender)); + } + let block_ref = evidence.reference(); + self.validate_block_ref(&block_ref)?; + let phase = match evidence.kind() { + StarfishRbcReferenceKindV3::Echo => RbcPhase::Echo, + StarfishRbcReferenceKindV3::Ready => RbcPhase::Ready, + }; + let committee = Arc::clone(&self.committee); + let slot = self.slot_mut(block_ref); + if !slot.record_phase_sender(phase, authenticated_sender, block_ref) { + return Ok(Vec::new()); + } + let candidate = slot + .candidates + .entry(block_ref) + .or_insert_with(CandidateState::new); + match phase { + RbcPhase::Echo => { + candidate.echoes.add(authenticated_sender, &committee); + } + RbcPhase::Ready => { + candidate.readies.add(authenticated_sender, &committee); + } + } + Ok(self.drive(block_ref)) + } + + /// Materialize one recipient-specific message for an untagged multicast + /// effect. The network adapter calls this once per non-local recipient. + pub(crate) fn make_phase_message( + &self, + phase: RbcPhase, + block_ref: BlockReference, + recipient: AuthorityIndex, + ) -> Result { + self.validate_block_ref(&block_ref)?; + let authorized = self.slot(&block_ref).is_some_and(|slot| { + let phase_authorized = match phase { + RbcPhase::Echo => slot.echoed == Some(block_ref), + RbcPhase::Ready => slot.readied == Some(block_ref), + }; + phase_authorized + && slot + .candidates + .get(&block_ref) + .is_some_and(|candidate| candidate.header.is_some()) + }); + if !authorized { + return Err(RbcError::PhaseNotAuthorized { phase, block_ref }); + } + if !self.committee.known_authority(recipient) { + return Err(RbcError::UnknownAuthority(recipient)); + } + if recipient == self.own_authority { + return Err(RbcError::LoopbackPhase); + } + let statement = encode_mac_statement( + &self.context, + phase.statement_kind(), + &block_ref, + self.own_authority, + recipient, + ); + let tag = self.mac_keys[recipient as usize].compute_rbc_tag(&statement); + Ok(RbcPhaseMessage { + block_ref, + sender: self.own_authority, + recipient, + phase, + tag, + }) + } + + fn ensure_local_initial(&self, local: &RbcLocalInitial) -> Result { + if local.context != self.context || local.author != self.own_authority { + return Err(RbcError::LocalInitialContextMismatch); + } + local.header.ensure_committee(self.context.committee_id)?; + let block_ref = local.header.reference(); + let selected = self.slot(&block_ref).is_some_and(|slot| { + slot.initial_candidate == Some(block_ref) + && slot.echoed == Some(block_ref) + && slot + .candidates + .get(&block_ref) + .and_then(|candidate| candidate.header.as_ref()) + == Some(&local.header) + }); + if !selected { + return Err(RbcError::LocalInitialNotSelected(block_ref)); + } + Ok(block_ref) + } + + pub(crate) fn make_local_initial_signature_digest( + &self, + local: &RbcLocalInitial, + ) -> Result<[u8; 32], RbcError> { + let block_ref = self.ensure_local_initial(local)?; + self.initial_signature_digest(block_ref) + } + + /// Produce the common 32-byte digest signed by Ed25519 or ML-DSA for an + /// initial Starfish-RBC header proposal. + fn initial_signature_digest(&self, block_ref: BlockReference) -> Result<[u8; 32], RbcError> { + if self.context.initial_authentication == BlockAuthenticationScheme::MacVector { + return Err(RbcError::InitialSignatureRequiresSignatureAuthentication); + } + self.validate_block_ref(&block_ref)?; + let statement = encode_base_statement(&self.context, INITIAL_KIND, &block_ref); + Ok(blake3::hash(&statement).into()) + } + + /// Produce one receiver-specific initial MAC. The local author calls this + /// separately for every non-local recipient. + pub(crate) fn make_local_initial_mac_tag( + &self, + local: &RbcLocalInitial, + recipient: AuthorityIndex, + ) -> Result { + let block_ref = self.ensure_local_initial(local)?; + self.make_initial_mac_tag_for_reference(block_ref, recipient) + } + + fn make_initial_mac_tag_for_reference( + &self, + block_ref: BlockReference, + recipient: AuthorityIndex, + ) -> Result { + if self.context.initial_authentication != BlockAuthenticationScheme::MacVector { + return Err(RbcError::InitialMacRequiresMacAuthentication); + } + self.validate_block_ref(&block_ref)?; + if block_ref.authority != self.own_authority { + return Err(RbcError::InitialAuthorMismatch { + expected: block_ref.authority, + actual: self.own_authority, + }); + } + if !self.committee.known_authority(recipient) { + return Err(RbcError::UnknownAuthority(recipient)); + } + if recipient == self.own_authority { + return Err(RbcError::LoopbackPhase); + } + let statement = encode_mac_statement( + &self.context, + INITIAL_KIND, + &block_ref, + self.own_authority, + recipient, + ); + Ok(self.mac_keys[recipient as usize].compute_rbc_tag(&statement)) + } + + /// Verify the local receiver's initial MAC from the direct block author. + pub(crate) fn verify_initial_mac_tag( + &self, + direct_peer: AuthorityIndex, + block_ref: BlockReference, + tag: &MacTag, + ) -> Result<(), RbcError> { + if self.context.initial_authentication != BlockAuthenticationScheme::MacVector { + return Err(RbcError::InitialMacRequiresMacAuthentication); + } + self.validate_block_ref(&block_ref)?; + if direct_peer != block_ref.authority { + return Err(RbcError::InitialAuthorMismatch { + expected: block_ref.authority, + actual: direct_peer, + }); + } + if direct_peer == self.own_authority { + return Err(RbcError::LoopbackPhase); + } + let statement = encode_mac_statement( + &self.context, + INITIAL_KIND, + &block_ref, + direct_peer, + self.own_authority, + ); + let expected = self.mac_keys[direct_peer as usize].compute_rbc_tag(&statement); + if expected != *tag { + return Err(RbcError::InvalidInitialTag); + } + Ok(()) + } + + #[allow(dead_code)] + pub(crate) fn header_holders(&self, block_ref: &BlockReference) -> AuthoritySet { + self.candidate(block_ref) + .map(CandidateState::holders) + .unwrap_or_default() + } + + /// Return the retained, content-validated header for a candidate. + /// + /// The service uses this accessor to answer recovery requests. Returning + /// the pin (rather than a detached header clone) preserves the invariant + /// that an honest ECHO/READY sender keeps the advertised content alive. + pub(crate) fn pinned_header( + &self, + block_ref: BlockReference, + ) -> Result, RbcError> { + self.validate_block_ref(&block_ref)?; + Ok(self + .candidate(&block_ref) + .and_then(|candidate| candidate.header.clone())) + } + + /// Recreate the current fetch effect for a durable retry timer. The first + /// `NeedHeader` effect is only a wake-up; recovery must retry until the + /// content-validated header becomes locally pinned. + pub(crate) fn retry_header_request( + &self, + block_ref: BlockReference, + ) -> Result, RbcError> { + self.validate_block_ref(&block_ref)?; + let Some(slot) = self.slot(&block_ref) else { + return Ok(None); + }; + let Some(candidate) = slot.candidates.get(&block_ref) else { + return Ok(None); + }; + let ready_trigger = candidate.echo_quorum_observed || candidate.ready_validity_observed; + let blocked_on_header = candidate.header.is_none() + && ((slot.readied.is_none() && ready_trigger) + || (slot.delivered.is_none() && candidate.ready_quorum_observed)); + Ok(blocked_on_header.then(|| RbcEffect::NeedHeader { + block_ref, + holders: candidate.holders(), + })) + } + + fn verify_phase_message( + &self, + direct_peer: AuthorityIndex, + message: &RbcPhaseMessage, + ) -> Result<(), RbcError> { + self.validate_block_ref(&message.block_ref)?; + if !self.committee.known_authority(direct_peer) { + return Err(RbcError::UnknownAuthority(direct_peer)); + } + if !self.committee.known_authority(message.sender) { + return Err(RbcError::UnknownAuthority(message.sender)); + } + if !self.committee.known_authority(message.recipient) { + return Err(RbcError::UnknownAuthority(message.recipient)); + } + if message.recipient != self.own_authority { + return Err(RbcError::WrongRecipient { + expected: self.own_authority, + actual: message.recipient, + }); + } + if message.sender != direct_peer { + return Err(RbcError::SenderPeerMismatch { + sender: message.sender, + peer: direct_peer, + }); + } + if message.sender == message.recipient { + return Err(RbcError::LoopbackPhase); + } + let statement = encode_mac_statement( + &self.context, + message.phase.statement_kind(), + &message.block_ref, + message.sender, + message.recipient, + ); + let expected = self.mac_keys[message.sender as usize].compute_rbc_tag(&statement); + if expected != message.tag { + return Err(RbcError::InvalidPhaseTag); + } + Ok(()) + } + + fn validate_block_ref(&self, block_ref: &BlockReference) -> Result<(), RbcError> { + if block_ref.round == 0 { + return Err(RbcError::GenesisSlot); + } + if !self.committee.known_authority(block_ref.authority) { + return Err(RbcError::UnknownAuthority(block_ref.authority)); + } + let maximum_round = self.maximum_admissible_round(); + if block_ref.round > maximum_round { + return Err(RbcError::FutureRound { + round: block_ref.round, + maximum: maximum_round, + }); + } + if block_ref.round < self.minimum_new_slot_round && self.slot(block_ref).is_none() { + return Err(RbcError::StaleRound { + round: block_ref.round, + minimum: self.minimum_new_slot_round, + }); + } + Ok(()) + } + + fn record_initial_candidate(&mut self, block_ref: BlockReference) -> Result<(), RbcError> { + self.validate_block_ref(&block_ref)?; + let slot = self.slot_mut(block_ref); + match slot.initial_candidate { + Some(existing) if existing != block_ref => Err(RbcError::ConflictingInitialHeader { + existing, + received: block_ref, + }), + Some(_) => Ok(()), + None => { + slot.initial_candidate = Some(block_ref); + Ok(()) + } + } + } + + fn slot_mut(&mut self, block_ref: BlockReference) -> &mut SlotState { + self.slots + .entry(block_ref.round) + .or_default() + .entry(block_ref.authority) + .or_default() + } + + fn slot(&self, block_ref: &BlockReference) -> Option<&SlotState> { + self.slots + .get(&block_ref.round) + .and_then(|round| round.get(&block_ref.authority)) + } + + fn candidate_mut(&mut self, block_ref: BlockReference) -> &mut CandidateState { + self.slot_mut(block_ref) + .candidates + .entry(block_ref) + .or_insert_with(CandidateState::new) + } + + fn candidate(&self, block_ref: &BlockReference) -> Option<&CandidateState> { + self.slot(block_ref) + .and_then(|slot| slot.candidates.get(block_ref)) + } + + fn drive(&mut self, block_ref: BlockReference) -> Vec { + let validity_threshold = self.committee.validity_threshold(); + let quorum_threshold = self.committee.quorum_threshold(); + let echo_qc_fast_path = self.echo_qc_fast_path; + let mut effects = Vec::new(); + + loop { + let action = { + let slot = self.slot_mut(block_ref); + let can_send_ready = slot.readied.is_none(); + let can_deliver = slot.delivered.is_none(); + let candidate = slot + .candidates + .entry(block_ref) + .or_insert_with(CandidateState::new); + candidate.latch_thresholds(validity_threshold, quorum_threshold); + + let ready_trigger = + candidate.echo_quorum_observed || candidate.ready_validity_observed; + let blocked_on_header = candidate.header.is_none() + && ((can_send_ready && ready_trigger) + || (can_deliver && candidate.ready_quorum_observed)); + let holders = candidate.holders(); + if blocked_on_header && holders != candidate.header_request_holders { + candidate.header_request_holders = holders; + ProgressAction::NeedHeader(holders) + } else if candidate.header.is_some() && can_send_ready && ready_trigger { + ProgressAction::SendReady + } else if candidate.header.is_some() + && can_deliver + && (candidate.ready_quorum_observed + || (echo_qc_fast_path && candidate.echo_quorum_observed)) + { + ProgressAction::Deliver + } else { + ProgressAction::None + } + }; + + match action { + ProgressAction::NeedHeader(holders) => { + effects.push(RbcEffect::NeedHeader { block_ref, holders }); + break; + } + ProgressAction::SendReady => { + let own_authority = self.own_authority; + let committee = Arc::clone(&self.committee); + let slot = self.slot_mut(block_ref); + if slot.readied.is_none() { + slot.readied = Some(block_ref); + let recorded = + slot.record_phase_sender(RbcPhase::Ready, own_authority, block_ref); + debug_assert!(recorded, "local READY guard and sender record diverged"); + slot.candidates + .entry(block_ref) + .or_insert_with(CandidateState::new) + .readies + .add(own_authority, &committee); + effects.push(RbcEffect::MulticastPhase { + phase: RbcPhase::Ready, + block_ref, + }); + } + } + ProgressAction::Deliver => { + let slot = self.slot_mut(block_ref); + if slot.delivered.is_none() { + slot.delivered = Some(block_ref); + let header = slot + .candidates + .get(&block_ref) + .and_then(|candidate| candidate.header.clone()) + .expect("delivery requires a pinned Starfish-RBC header"); + effects.push(RbcEffect::Deliver(header)); + } + } + ProgressAction::None => break, + } + } + + effects + } +} + +fn validate_committee(committee: &Committee) -> Result<(), RbcError> { + if committee.is_empty() { + return Err(RbcError::EmptyCommittee); + } + if committee.len() > MAX_COMMITTEE_SIZE as usize { + return Err(RbcError::CommitteeTooLarge(committee.len())); + } + let mut total_stake = 0u64; + for authority in committee.authorities() { + let stake = committee + .get_stake(authority) + .ok_or(RbcError::UnknownAuthority(authority))?; + if stake == 0 { + return Err(RbcError::InvalidCommitteeStake(authority)); + } + total_stake = total_stake + .checked_add(stake) + .ok_or(RbcError::TotalStakeOverflow)?; + } + let expected_validity = total_stake / 3 + 1; + let expected_quorum = total_stake + .checked_mul(2) + .ok_or(RbcError::TotalStakeOverflow)? + / 3 + + 1; + if committee.validity_threshold() != expected_validity { + return Err(RbcError::InvalidValidityThreshold { + expected: expected_validity, + actual: committee.validity_threshold(), + }); + } + if committee.quorum_threshold() != expected_quorum { + return Err(RbcError::InvalidQuorumThreshold { + expected: expected_quorum, + actual: committee.quorum_threshold(), + }); + } + let committee_size = committee.len(); + let f = (committee_size - 1) / 3; + let expected_info_length = match committee_size % 3 { + 0 => f + 3, + 1 => f + 1, + _ => f + 2, + }; + if committee.info_length() != expected_info_length { + return Err(RbcError::InvalidDerivedInfoLength { + expected: expected_info_length, + actual: committee.info_length(), + }); + } + Ok(()) +} + +fn authentication_code(authentication: BlockAuthenticationScheme) -> u8 { + match authentication { + BlockAuthenticationScheme::Ed25519 => 0x00, + BlockAuthenticationScheme::MlDsa44 => 0x01, + BlockAuthenticationScheme::MlDsa65 => 0x02, + BlockAuthenticationScheme::MacVector => 0x03, + } +} + +fn encode_base_statement( + context: &RbcContext, + kind: u8, + block_ref: &BlockReference, +) -> [u8; BASE_STATEMENT_SIZE] { + let mut statement = [0u8; BASE_STATEMENT_SIZE]; + statement[..15].copy_from_slice(PROTOCOL_DOMAIN); + statement[15] = kind; + statement[16] = authentication_code(context.initial_authentication); + statement[17..49].copy_from_slice(&context.protocol_instance.0); + statement[49..81].copy_from_slice(&context.committee_id.0); + statement[81..83].copy_from_slice(&block_ref.authority.to_be_bytes()); + statement[83..87].copy_from_slice(&block_ref.round.to_be_bytes()); + statement[87..119].copy_from_slice(block_ref.digest.as_ref()); + statement +} + +fn encode_mac_statement( + context: &RbcContext, + kind: u8, + block_ref: &BlockReference, + sender: AuthorityIndex, + recipient: AuthorityIndex, +) -> [u8; MAC_STATEMENT_SIZE] { + let mut statement = [0u8; MAC_STATEMENT_SIZE]; + statement[..BASE_STATEMENT_SIZE] + .copy_from_slice(&encode_base_statement(context, kind, block_ref)); + statement[119..121].copy_from_slice(&sender.to_be_bytes()); + statement[121..123].copy_from_slice(&recipient.to_be_bytes()); + statement +} + +#[cfg(test)] +mod tests { + use std::collections::{HashSet, VecDeque}; + + use super::*; + use crate::{ + crypto::{ + dummy_ml_dsa_44_signer, dummy_ml_dsa_65_signer, dummy_signer, mac_keyrings_for_test, + }, + types::{BlockDigest, BlockReference}, + }; + + const TEST_INSTANCE_BYTE: u8 = 0xA5; + type DeliveryTrace = Vec>; + type RecoveryTrace = Vec<(AuthorityIndex, AuthorityIndex, BlockReference)>; + + fn block(authority: AuthorityIndex, round: RoundNumber, marker: u8) -> BlockReference { + BlockReference { + authority, + round, + digest: BlockDigest::from([marker; 32]), + } + } + + fn pinned_header_for_context( + context: RbcContext, + block_ref: BlockReference, + ) -> PinnedRbcHeader { + PinnedRbcHeader { + header: Arc::new(RbcCanonicalHeader { + reference: block_ref, + block_references: Vec::new(), + acknowledgments: RbcAckFields { + intersection: Some(0), + extra_references: Vec::new(), + }, + meta_creation_time_ns: 0, + transactions_commitment: TransactionsCommitment::default(), + starfish_rbc_v3: None, + }), + committee_id: context.committee_id, + } + } + + fn pinned_header(committee: &Committee, block_ref: BlockReference) -> PinnedRbcHeader { + pinned_header_for_context( + RbcContext::new( + instance(TEST_INSTANCE_BYTE), + committee, + BlockAuthenticationScheme::Ed25519, + ) + .unwrap(), + block_ref, + ) + } + + #[test] + fn embedded_block_references_drive_rbc_without_phase_messages() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = mac_keyrings_for_test(committee.len()); + let mut receiver = StarfishRbcKernel::new( + committee.clone(), + 0, + instance(TEST_INSTANCE_BYTE), + BlockAuthenticationScheme::MacVector, + Arc::new(keyrings[0].clone()), + 1, + ) + .unwrap(); + let target = block(3, 1, 0x71); + receiver + .note_header_available(pinned_header_for_context(receiver.context, target)) + .unwrap(); + receiver.authorize_echo(target).unwrap(); + + let echo = StarfishRbcReferenceV3::new(StarfishRbcReferenceKindV3::Echo, target); + assert!( + receiver + .handle_embedded_reference(1, echo) + .unwrap() + .is_empty() + ); + let effects = receiver.handle_embedded_reference(2, echo).unwrap(); + assert!(effects.iter().any(|effect| matches!( + effect, + RbcEffect::MulticastPhase { + phase: RbcPhase::Ready, + block_ref, + } if *block_ref == target + ))); + assert!( + !effects + .iter() + .any(|effect| matches!(effect, RbcEffect::Deliver(_))) + ); + + let ready = StarfishRbcReferenceV3::new(StarfishRbcReferenceKindV3::Ready, target); + assert!( + receiver + .handle_embedded_reference(1, ready) + .unwrap() + .is_empty() + ); + let effects = receiver.handle_embedded_reference(2, ready).unwrap(); + assert!(effects.iter().any(|effect| matches!( + effect, + RbcEffect::Deliver(header) if header.reference() == target + ))); + } + + #[test] + fn flagged_echo_qc_fast_path_delivers_unique_header_without_ready_quorum() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = mac_keyrings_for_test(committee.len()); + let mut receiver = StarfishRbcKernel::new_with_echo_qc_fast_path( + committee, + 0, + instance(TEST_INSTANCE_BYTE), + BlockAuthenticationScheme::MacVector, + Arc::new(keyrings[0].clone()), + 1, + true, + ) + .unwrap(); + let target = block(3, 1, 0x72); + receiver + .note_header_available(pinned_header_for_context(receiver.context, target)) + .unwrap(); + receiver.authorize_echo(target).unwrap(); + + let echo = StarfishRbcReferenceV3::new(StarfishRbcReferenceKindV3::Echo, target); + assert!( + receiver + .handle_embedded_reference(1, echo) + .unwrap() + .is_empty() + ); + let effects = receiver.handle_embedded_reference(2, echo).unwrap(); + + assert!(effects.iter().any(|effect| matches!( + effect, + RbcEffect::MulticastPhase { + phase: RbcPhase::Ready, + block_ref, + } if *block_ref == target + ))); + assert!(effects.iter().any(|effect| matches!( + effect, + RbcEffect::Deliver(header) if header.reference() == target + ))); + } + + fn valid_canonical_header( + authority: AuthorityIndex, + round: RoundNumber, + marker: u8, + ) -> RbcCanonicalHeader { + let parents = (0..3) + .map(|parent_authority| { + block( + parent_authority, + round - 1, + marker.wrapping_add(parent_authority as u8), + ) + }) + .collect(); + RbcCanonicalHeader::try_new( + authority, + round, + parents, + Vec::new(), + 0x0102_0304_0506_0708, + TransactionsCommitment::default(), + ) + .unwrap() + } + + fn block_header_from_canonical( + header: &RbcCanonicalHeader, + authentication: BlockAuthentication, + ) -> BlockHeader { + BlockHeader { + reference: header.reference, + block_references: header.block_references.clone(), + meta_creation_time_ns: header.meta_creation_time_ns, + authentication, + transactions_commitment: Some(header.transactions_commitment), + ack: Some(header.acknowledgment_fields()), + strong_vote: None, + bls: None, + sailfish: None, + unprovable_certificate: None, + starfish_rbc_v3: None, + serialized: None, + } + } + + fn instance(marker: u8) -> RbcProtocolInstanceId { + RbcProtocolInstanceId::new([marker; 32]).unwrap() + } + + fn kernel( + committee: Arc, + keyrings: &[Vec], + own_authority: AuthorityIndex, + authentication: BlockAuthenticationScheme, + ) -> StarfishRbcKernel { + kernel_with_instance( + committee, + keyrings, + own_authority, + authentication, + TEST_INSTANCE_BYTE, + ) + } + + fn kernel_with_instance( + committee: Arc, + keyrings: &[Vec], + own_authority: AuthorityIndex, + authentication: BlockAuthenticationScheme, + instance_byte: u8, + ) -> StarfishRbcKernel { + StarfishRbcKernel::new( + committee, + own_authority, + instance(instance_byte), + authentication, + Arc::new(keyrings[own_authority as usize].clone()), + 0, + ) + .unwrap() + } + + fn phase_message( + committee: Arc, + keyrings: &[Vec], + sender: AuthorityIndex, + recipient: AuthorityIndex, + phase: RbcPhase, + block_ref: BlockReference, + ) -> RbcPhaseMessage { + let context = RbcContext::new( + instance(TEST_INSTANCE_BYTE), + committee.as_ref(), + BlockAuthenticationScheme::Ed25519, + ) + .unwrap(); + let statement = encode_mac_statement( + &context, + phase.statement_kind(), + &block_ref, + sender, + recipient, + ); + RbcPhaseMessage { + block_ref, + sender, + recipient, + phase, + tag: keyrings[sender as usize][recipient as usize].compute_rbc_tag(&statement), + } + } + + fn authorize_echo(kernel: &mut StarfishRbcKernel, block_ref: BlockReference) { + let header = pinned_header_for_context(kernel.context(), block_ref); + kernel.note_header_available(header).unwrap(); + assert!(matches!( + kernel.authorize_echo(block_ref).unwrap().as_slice(), + [RbcEffect::MulticastPhase { + phase: RbcPhase::Echo, + .. + }] + )); + } + + fn holder_vec(holders: AuthoritySet) -> Vec { + holders.present().collect() + } + + fn pump_phase_effects( + kernels: &mut [StarfishRbcKernel], + initial_effects: Vec<(AuthorityIndex, Vec)>, + header_stores: &mut [AHashMap], + ) -> (DeliveryTrace, RecoveryTrace) { + let mut queue: VecDeque<_> = initial_effects + .into_iter() + .flat_map(|(authority, effects)| { + effects.into_iter().map(move |effect| (authority, effect)) + }) + .collect(); + let mut deliveries = vec![Vec::new(); kernels.len()]; + let mut recoveries = Vec::new(); + + while let Some((owner, effect)) = queue.pop_front() { + match effect { + RbcEffect::MulticastPhase { phase, block_ref } => { + let messages: Vec<_> = (0..kernels.len()) + .filter(|recipient| *recipient != owner as usize) + .map(|recipient| { + let recipient = recipient as AuthorityIndex; + ( + recipient, + kernels[owner as usize] + .make_phase_message(phase, block_ref, recipient) + .unwrap(), + ) + }) + .collect(); + for (recipient, message) in messages { + let effects = kernels[recipient as usize] + .handle_phase(owner, message) + .unwrap(); + queue.extend(effects.into_iter().map(|effect| (recipient, effect))); + } + } + RbcEffect::NeedHeader { block_ref, holders } => { + let (source, header) = holders + .present() + .find_map(|source| { + header_stores[source as usize] + .get(&block_ref) + .cloned() + .map(|header| (source, header)) + }) + .expect("a test RBC holder must retain the canonical header"); + let effects = kernels[owner as usize] + .accept_recovered_header(header.clone()) + .unwrap(); + header_stores[owner as usize].insert(block_ref, header); + recoveries.push((owner, source, block_ref)); + queue.extend(effects.into_iter().map(|effect| (owner, effect))); + } + RbcEffect::Deliver(header) => { + assert_eq!( + header_stores[owner as usize].get(&header.reference()), + Some(header.header()) + ); + deliveries[owner as usize].push(header.reference()); + } + } + } + (deliveries, recoveries) + } + + #[test] + fn canonical_statement_layout_is_fixed_width_and_big_endian() { + let context = RbcContext { + protocol_instance: RbcProtocolInstanceId([0x11; 32]), + committee_id: RbcCommitteeId([0x22; 32]), + initial_authentication: BlockAuthenticationScheme::MlDsa65, + }; + let block_ref = BlockReference { + authority: 0x0102, + round: 0x0304_0506, + digest: BlockDigest::from([0x33; 32]), + }; + let statement = encode_mac_statement(&context, ECHO_KIND, &block_ref, 0x0708, 0x090A); + + assert_eq!(statement.len(), 123); + assert_eq!(&statement[..15], PROTOCOL_DOMAIN); + assert_eq!(statement[15], ECHO_KIND); + assert_eq!(statement[16], 0x02); + assert_eq!(&statement[17..49], &[0x11; 32]); + assert_eq!(&statement[49..81], &[0x22; 32]); + assert_eq!(&statement[81..83], &[0x01, 0x02]); + assert_eq!(&statement[83..87], &[0x03, 0x04, 0x05, 0x06]); + assert_eq!(&statement[87..119], &[0x33; 32]); + assert_eq!(&statement[119..121], &[0x07, 0x08]); + assert_eq!(&statement[121..123], &[0x09, 0x0A]); + } + + #[test] + fn canonical_header_digest_has_a_frozen_tagged_encoding() { + let parent = block(0x0708, 0x090A_0B0C, 0x11); + let acknowledgment = block(0x0D0E, 0x0F10_1112, 0x22); + let timestamp: TimestampNs = 0x1314_1516_1718_191A; + let commitment = TransactionsCommitment::default(); + + let mut encoded = Vec::new(); + encoded.extend_from_slice(&[0x01]); + encoded.extend_from_slice(&0x0102u16.to_be_bytes()); + encoded.extend_from_slice(&[0x02]); + encoded.extend_from_slice(&0x0304_0506u32.to_be_bytes()); + encoded.extend_from_slice(&[0x03]); + encoded.extend_from_slice(&1u32.to_be_bytes()); + encoded.extend_from_slice(&parent.authority.to_be_bytes()); + encoded.extend_from_slice(&parent.round.to_be_bytes()); + encoded.extend_from_slice(parent.digest.as_ref()); + encoded.extend_from_slice(&[0x04]); + encoded.extend_from_slice(&1u32.to_be_bytes()); + encoded.extend_from_slice(&acknowledgment.authority.to_be_bytes()); + encoded.extend_from_slice(&acknowledgment.round.to_be_bytes()); + encoded.extend_from_slice(acknowledgment.digest.as_ref()); + encoded.extend_from_slice(&[0x05]); + encoded.extend_from_slice(×tamp.to_be_bytes()); + encoded.extend_from_slice(&[0x06]); + encoded.extend_from_slice(commitment.as_ref()); + + assert_eq!( + hex::encode(&encoded), + concat!( + "010102020304050603000000010708090a0b0c", + "11111111111111111111111111111111", + "11111111111111111111111111111111", + "04000000010d0e0f101112", + "22222222222222222222222222222222", + "22222222222222222222222222222222", + "051314", + "15161718191a060000000000000000000000000000000000000000000000000000", + "000000000000" + ) + ); + let digest = BlockDigest::new_starfish_rbc_header( + 0x0102, + 0x0304_0506, + &[parent], + &[acknowledgment], + timestamp, + commitment, + ); + assert_eq!(digest.as_ref(), blake3::hash(&encoded).as_bytes()); + assert_eq!( + hex::encode(digest.as_ref()), + "3a0ef697511a95ddf97c73e72aad2cb2313839063f7249fe0f967f2d8ff3ad22" + ); + } + + #[test] + fn canonical_digest_separates_the_legacy_parent_ack_boundary() { + let references: Vec<_> = (0..4) + .map(|authority| block(authority, 7, 0x30 + authority as u8)) + .collect(); + let commitment = TransactionsCommitment::default(); + let legacy_first = BlockDigest::new( + 0, + 8, + &references[..3], + &references[3..], + 42, + Some(commitment), + None, + ); + let legacy_second = BlockDigest::new(0, 8, &references, &[], 42, Some(commitment), None); + assert_eq!(legacy_first, legacy_second); + + let canonical_first = BlockDigest::new_starfish_rbc_header( + 0, + 8, + &references[..3], + &references[3..], + 42, + commitment, + ); + let canonical_second = + BlockDigest::new_starfish_rbc_header(0, 8, &references, &[], 42, commitment); + assert_ne!(canonical_first, canonical_second); + } + + #[test] + fn canonical_digest_binds_every_content_field_and_order() { + let first = block(0, 4, 0x41); + let second = block(1, 4, 0x42); + let commitment = TransactionsCommitment::default(); + let other_commitment = TransactionsCommitment::new_from_transactions(&Vec::new()); + let digest = + BlockDigest::new_starfish_rbc_header(2, 5, &[first, second], &[first], 7, commitment); + + for changed in [ + BlockDigest::new_starfish_rbc_header(3, 5, &[first, second], &[first], 7, commitment), + BlockDigest::new_starfish_rbc_header(2, 6, &[first, second], &[first], 7, commitment), + BlockDigest::new_starfish_rbc_header(2, 5, &[second, first], &[first], 7, commitment), + BlockDigest::new_starfish_rbc_header(2, 5, &[first, second], &[second], 7, commitment), + BlockDigest::new_starfish_rbc_header(2, 5, &[first, second], &[first], 8, commitment), + BlockDigest::new_starfish_rbc_header( + 2, + 5, + &[first, second], + &[first], + 7, + other_commitment, + ), + ] { + assert_ne!(digest, changed); + } + } + + #[test] + fn canonical_header_validation_is_authentication_independent_and_pins_content() { + let committee = Committee::new_test(vec![1; 4]); + let canonical = valid_canonical_header(3, 5, 0x51); + let without_authentication = + block_header_from_canonical(&canonical, BlockAuthentication::None); + let with_authentication = block_header_from_canonical( + &canonical, + BlockAuthentication::Ed25519(SignatureBytes::default()), + ); + + let extracted_without = + RbcCanonicalHeader::from_block_header(&without_authentication).unwrap(); + let extracted_with = RbcCanonicalHeader::from_block_header(&with_authentication).unwrap(); + assert_eq!(extracted_without, canonical); + assert_eq!(extracted_with, canonical); + canonical.validate_for_committee(&committee).unwrap(); + + let carrier = canonical.to_authentication_free_block(); + assert_eq!(carrier.reference(), &canonical.reference()); + assert_eq!(carrier.authentication(), &BlockAuthentication::None); + assert!(!carrier.has_transaction_data()); + assert_eq!( + RbcCanonicalHeader::from_block_header(carrier.header()).unwrap(), + canonical + ); + + let pinned = PinnedRbcHeader::validate(canonical.clone(), &committee).unwrap(); + let retained = pinned.clone(); + assert_eq!(pinned.reference(), canonical.reference()); + assert!(Arc::ptr_eq(&pinned.header, &retained.header)); + assert_eq!(pinned.header(), &canonical); + } + + #[test] + fn block_header_conversion_rejects_missing_fields_and_extensions() { + let canonical = valid_canonical_header(3, 5, 0x52); + let mut header = block_header_from_canonical(&canonical, BlockAuthentication::None); + header.ack = None; + assert_eq!( + RbcCanonicalHeader::from_block_header(&header), + Err(RbcError::MissingAcknowledgments) + ); + + let mut header = block_header_from_canonical(&canonical, BlockAuthentication::None); + header.transactions_commitment = None; + assert_eq!( + RbcCanonicalHeader::from_block_header(&header), + Err(RbcError::MissingTransactionsCommitment) + ); + + let mut header = block_header_from_canonical(&canonical, BlockAuthentication::None); + header.strong_vote = Some(AuthoritySet::default()); + assert_eq!( + RbcCanonicalHeader::from_block_header(&header), + Err(RbcError::ForbiddenHeaderExtensions) + ); + + let mut header = block_header_from_canonical(&canonical, BlockAuthentication::None); + header.block_references = vec![block(0, 4, 0x51); MAX_RBC_REFERENCES_PER_FIELD + 1]; + assert!(matches!( + RbcCanonicalHeader::from_block_header(&header), + Err(RbcError::TooManyHeaderReferences { + field: "parent", + .. + }) + )); + + let mut header = block_header_from_canonical(&canonical, BlockAuthentication::None); + header.ack = Some(AckFields { + intersection: None, + extra_references: vec![block(0, 4, 0x51); MAX_RBC_REFERENCES_PER_FIELD + 1], + }); + assert!(matches!( + RbcCanonicalHeader::from_block_header(&header), + Err(RbcError::TooManyHeaderReferences { + field: "acknowledgment", + .. + }) + )); + + let mut header = block_header_from_canonical(&canonical, BlockAuthentication::None); + header.block_references = vec![block(0, 4, 0x51); MAX_RBC_REFERENCES_PER_FIELD]; + header.ack = Some(AckFields { + intersection: Some(0), + extra_references: vec![block(1, 4, 0x52)], + }); + assert!(matches!( + RbcCanonicalHeader::from_block_header(&header), + Err(RbcError::TooManyHeaderReferences { + field: "acknowledgment", + .. + }) + )); + } + + #[test] + fn acknowledgment_compression_is_canonical_and_preserves_u8_boundary() { + let committee = Committee::new_test(vec![1; 4]); + let canonical = valid_canonical_header(3, 5, 0x53); + assert!(PinnedRbcHeader::validate(canonical.clone(), &committee).is_ok()); + + let mut legacy_alias = canonical.clone(); + legacy_alias.acknowledgments.intersection = None; + legacy_alias.acknowledgments.extra_references.clear(); + assert_eq!( + PinnedRbcHeader::validate(legacy_alias, &committee), + Err(RbcError::NonCanonicalAcknowledgments) + ); + + let mut out_of_range = canonical; + out_of_range.acknowledgments.intersection = Some(4); + assert_eq!( + PinnedRbcHeader::validate(out_of_range, &committee), + Err(RbcError::NonCanonicalAcknowledgments) + ); + + let references = |count: usize| { + (0..count) + .map(|index| { + let mut digest = [0; 32]; + digest[..4].copy_from_slice(&(index as u32).to_be_bytes()); + BlockReference { + authority: index as AuthorityIndex % 4, + round: 4, + digest: BlockDigest::from(digest), + } + }) + .collect::>() + }; + let parents_255 = references(255); + let parents_256 = references(256); + assert_eq!( + RbcAckFields::from_logical(&parents_255, &[]).intersection, + Some(255) + ); + assert_eq!( + RbcAckFields::from_logical(&parents_256, &[]), + RbcAckFields { + intersection: None, + extra_references: Vec::new(), + } + ); + } + + #[test] + fn canonical_header_wire_keeps_starfish_acknowledgment_compression() { + #[derive(Serialize)] + struct ExpandedHeader<'a> { + reference: BlockReference, + block_references: &'a [BlockReference], + acknowledgments: &'a [BlockReference], + meta_creation_time_ns: TimestampNs, + transactions_commitment: TransactionsCommitment, + } + + let parents: Vec<_> = (0..100) + .map(|index| { + let mut digest = [0; 32]; + digest[..4].copy_from_slice(&(index as u32).to_be_bytes()); + BlockReference { + authority: index % 4, + round: 4, + digest: BlockDigest::from(digest), + } + }) + .collect(); + let logical_acknowledgments = parents[50..].to_vec(); + let header = RbcCanonicalHeader::try_new( + 3, + 5, + parents, + logical_acknowledgments.clone(), + 7, + TransactionsCommitment::default(), + ) + .unwrap(); + assert_eq!(header.acknowledgments.intersection, Some(50)); + assert!(header.acknowledgments.extra_references.is_empty()); + + let compressed_size = bincode::serialize(&header).unwrap().len(); + let expanded_size = bincode::serialize(&ExpandedHeader { + reference: header.reference, + block_references: &header.block_references, + acknowledgments: &logical_acknowledgments, + meta_creation_time_ns: header.meta_creation_time_ns, + transactions_commitment: header.transactions_commitment, + }) + .unwrap() + .len(); + assert!(compressed_size < expanded_size); + } + + #[test] + fn canonical_header_validation_rejects_duplicate_and_invalid_references() { + let committee = Committee::new_test(vec![1; 4]); + let valid = valid_canonical_header(3, 5, 0x54); + + let same_round_ack = RbcCanonicalHeader::try_new( + 3, + 5, + valid.block_references.clone(), + vec![block(2, 5, 0x59)], + valid.meta_creation_time_ns, + valid.transactions_commitment, + ) + .unwrap(); + assert!(PinnedRbcHeader::validate(same_round_ack, &committee).is_ok()); + + let mut duplicate_parent = valid.clone(); + duplicate_parent + .block_references + .push(duplicate_parent.block_references[0]); + duplicate_parent.reference.digest = BlockDigest::new_starfish_rbc_header( + duplicate_parent.reference.authority, + duplicate_parent.reference.round, + &duplicate_parent.block_references, + &duplicate_parent.acknowledgment_references(), + duplicate_parent.meta_creation_time_ns, + duplicate_parent.transactions_commitment, + ); + assert!(matches!( + PinnedRbcHeader::validate(duplicate_parent, &committee), + Err(RbcError::DuplicateParent(_)) + )); + + let duplicate_ack = block(3, 5, 0x55); + assert!(matches!( + RbcCanonicalHeader::try_new( + 3, + 5, + valid.block_references.clone(), + vec![duplicate_ack, duplicate_ack], + valid.meta_creation_time_ns, + valid.transactions_commitment, + ), + Err(RbcError::DuplicateAcknowledgment(_)) + )); + + let shared_parent = valid.block_references[2]; + let extra = block(3, 5, 0x5A); + let normalized = RbcCanonicalHeader::try_new( + 3, + 5, + valid.block_references.clone(), + vec![extra, shared_parent], + valid.meta_creation_time_ns, + valid.transactions_commitment, + ); + assert_eq!( + normalized.unwrap().acknowledgment_references(), + vec![shared_parent, extra] + ); + + let future_ack = block(3, 6, 0x56); + let future_ack_header = RbcCanonicalHeader::try_new( + 3, + 5, + valid.block_references.clone(), + vec![future_ack], + valid.meta_creation_time_ns, + valid.transactions_commitment, + ) + .unwrap(); + assert!(matches!( + PinnedRbcHeader::validate(future_ack_header, &committee), + Err(RbcError::AcknowledgmentFromFuture(_)) + )); + + let unknown_ack_header = RbcCanonicalHeader::try_new( + 3, + 5, + valid.block_references.clone(), + vec![block(4, 5, 0x5B)], + valid.meta_creation_time_ns, + valid.transactions_commitment, + ) + .unwrap(); + assert_eq!( + PinnedRbcHeader::validate(unknown_ack_header, &committee), + Err(RbcError::UnknownAuthority(4)) + ); + + let mut unknown_parent = valid.clone(); + unknown_parent.block_references[0].authority = 4; + unknown_parent.reference.digest = BlockDigest::new_starfish_rbc_header( + unknown_parent.reference.authority, + unknown_parent.reference.round, + &unknown_parent.block_references, + &unknown_parent.acknowledgment_references(), + unknown_parent.meta_creation_time_ns, + unknown_parent.transactions_commitment, + ); + assert_eq!( + PinnedRbcHeader::validate(unknown_parent, &committee), + Err(RbcError::UnknownAuthority(4)) + ); + + let mut same_round_parent = valid.clone(); + same_round_parent.block_references[0].round = same_round_parent.reference.round; + same_round_parent.reference.digest = BlockDigest::new_starfish_rbc_header( + same_round_parent.reference.authority, + same_round_parent.reference.round, + &same_round_parent.block_references, + &same_round_parent.acknowledgment_references(), + same_round_parent.meta_creation_time_ns, + same_round_parent.transactions_commitment, + ); + assert!(matches!( + PinnedRbcHeader::validate(same_round_parent, &committee), + Err(RbcError::ParentNotPast(_)) + )); + + let insufficient_parents = RbcCanonicalHeader::try_new( + 3, + 5, + valid.block_references[..2].to_vec(), + Vec::new(), + valid.meta_creation_time_ns, + valid.transactions_commitment, + ) + .unwrap(); + assert_eq!( + PinnedRbcHeader::validate(insufficient_parents, &committee), + Err(RbcError::InvalidThresholdClock) + ); + + let mut wrong_digest = valid; + wrong_digest.reference.digest = BlockDigest::from([0xFF; 32]); + assert!(matches!( + PinnedRbcHeader::validate(wrong_digest, &committee), + Err(RbcError::HeaderDigestMismatch { .. }) + )); + + let keyrings = mac_keyrings_for_test(4); + let kernel = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + ); + let mut genesis = valid_canonical_header(0, 5, 0x5C); + genesis.reference.round = 0; + assert_eq!( + kernel.validate_header_content(genesis), + Err(RbcError::GenesisSlot) + ); + } + + #[test] + fn future_round_admission_advances_monotonically() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let mut kernel = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + ); + let too_far = block(0, MAX_RBC_FUTURE_ROUNDS + 1, 0x57); + assert!(matches!( + kernel.handle_phase( + 1, + phase_message( + Arc::clone(&kernel.committee), + &keyrings, + 1, + 0, + RbcPhase::Echo, + too_far, + ), + ), + Err(RbcError::FutureRound { .. }) + )); + assert!(kernel.slots.is_empty()); + + kernel.advance_local_round(1).unwrap(); + assert_eq!(kernel.maximum_admissible_round(), MAX_RBC_FUTURE_ROUNDS + 1); + assert_eq!( + kernel.advance_local_round(0), + Err(RbcError::RoundRegression { + current: 1, + proposed: 0, + }) + ); + } + + #[test] + fn retained_round_floor_rejects_only_unseen_slots() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + let first = valid_canonical_header(0, 5, 0xD0).reference(); + receiver + .handle_phase( + 0, + phase_message( + Arc::clone(&committee), + &keyrings, + 0, + 3, + RbcPhase::Echo, + first, + ), + ) + .unwrap(); + + receiver.advance_local_round(10).unwrap(); + assert_eq!( + receiver.close_new_slots_before(11), + Err(RbcError::RetainedRoundAheadOfLocal { + local: 10, + proposed: 11, + }) + ); + receiver.close_new_slots_before(10).unwrap(); + assert_eq!(receiver.minimum_new_slot_round(), 10); + assert_eq!( + receiver.close_new_slots_before(9), + Err(RbcError::RetainedRoundRegression { + current: 10, + proposed: 9, + }) + ); + + let retained_candidate = valid_canonical_header(0, 5, 0xD1); + let retained_ref = retained_candidate.reference(); + receiver + .handle_phase( + 1, + phase_message( + Arc::clone(&committee), + &keyrings, + 1, + 3, + RbcPhase::Ready, + retained_ref, + ), + ) + .unwrap(); + assert!( + receiver + .accept_recovered_header(retained_candidate) + .unwrap() + .is_empty() + ); + + let unseen = block(1, 5, 0xD2); + assert_eq!( + receiver.handle_phase( + 0, + phase_message( + Arc::clone(&committee), + &keyrings, + 0, + 3, + RbcPhase::Echo, + unseen, + ), + ), + Err(RbcError::StaleRound { + round: 5, + minimum: 10, + }) + ); + assert!(receiver.slots[&5].get(&1).is_none()); + assert!(receiver.candidate(&retained_ref).unwrap().header.is_some()); + } + + #[test] + fn phase_sender_admission_bounds_equivocation_without_burning_invalid_messages() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let canonical = valid_canonical_header(0, 6, 0xD3); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + let outcome = receiver + .accept_direct_initial_header( + 0, + canonical, + &RbcInitialProof::Ed25519(SignatureBytes::default()), + ) + .unwrap(); + assert!(matches!( + outcome, + RbcInitialHeaderOutcome::StagedUnauthenticated { .. } + )); + let conflicting_initial = valid_canonical_header(0, 6, 0xD5); + let conflicting_ref = conflicting_initial.reference(); + assert!(matches!( + receiver.accept_direct_initial_header( + 0, + conflicting_initial, + &RbcInitialProof::Ed25519(SignatureBytes::default()), + ), + Err(RbcError::ConflictingInitialHeader { received, .. }) if received == conflicting_ref + )); + + let mut invalid = phase_message( + Arc::clone(&committee), + &keyrings, + 0, + 3, + RbcPhase::Echo, + block(0, 6, 0xD4), + ); + invalid.tag = MacTag::from_bytes([0; 32]); + assert_eq!( + receiver.handle_phase(0, invalid), + Err(RbcError::InvalidPhaseTag) + ); + + let mut first_echo = None; + let mut first_ready = None; + for sender in 0..3 { + let echo = block(0, 6, 0xE0 + sender as u8); + let ready = block(0, 6, 0xF0 + sender as u8); + first_echo.get_or_insert(echo); + first_ready.get_or_insert(ready); + receiver + .handle_phase( + sender, + phase_message( + Arc::clone(&committee), + &keyrings, + sender, + 3, + RbcPhase::Echo, + echo, + ), + ) + .unwrap(); + receiver + .handle_phase( + sender, + phase_message( + Arc::clone(&committee), + &keyrings, + sender, + 3, + RbcPhase::Ready, + ready, + ), + ) + .unwrap(); + } + + for marker in 0..32 { + for phase in [RbcPhase::Echo, RbcPhase::Ready] { + let message = phase_message( + Arc::clone(&committee), + &keyrings, + 0, + 3, + phase, + block(0, 6, marker), + ); + assert!(receiver.handle_phase(0, message).unwrap().is_empty()); + } + } + let slot = receiver.slot(&block(0, 6, 0)).unwrap(); + assert_eq!(slot.candidates.len(), 1 + 2 * (committee.len() - 1)); + assert!(slot.candidates.contains_key(&first_echo.unwrap())); + assert!(slot.candidates.contains_key(&first_ready.unwrap())); + } + + #[test] + fn recovered_headers_require_prior_authenticated_phase_evidence() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let canonical = valid_canonical_header(0, 8, 0xD6); + let block_ref = canonical.reference(); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + + assert_eq!( + receiver.accept_recovered_header(canonical.clone()), + Err(RbcError::UnexpectedRecoveredHeader(block_ref)) + ); + assert!(receiver.slots.is_empty()); + + receiver + .handle_phase( + 0, + phase_message(committee, &keyrings, 0, 3, RbcPhase::Ready, block_ref), + ) + .unwrap(); + assert!( + receiver + .accept_recovered_header(canonical) + .unwrap() + .is_empty() + ); + assert!(receiver.candidate(&block_ref).unwrap().header.is_some()); + } + + #[test] + fn bounded_header_decoder_rejects_oversized_reference_vector() { + #[derive(Serialize)] + struct UnboundedReferences { + references: Vec, + } + #[derive(Deserialize)] + struct BoundedReferences { + #[serde(with = "super::bounded_references")] + _references: Vec, + } + + let oversized = UnboundedReferences { + references: vec![block(0, 1, 0x58); MAX_RBC_REFERENCES_PER_FIELD + 1], + }; + let bytes = bincode::serialize(&oversized).unwrap(); + assert!(bincode::deserialize::(&bytes).is_err()); + + #[derive(Serialize)] + struct UnboundedAckFields { + intersection: Option, + extra_references: Vec, + } + #[derive(Serialize)] + struct UnboundedHeader { + reference: BlockReference, + block_references: Vec, + acknowledgments: UnboundedAckFields, + meta_creation_time_ns: TimestampNs, + transactions_commitment: TransactionsCommitment, + } + + let base = valid_canonical_header(0, 5, 0x59); + let oversized_parent_header = UnboundedHeader { + reference: base.reference, + block_references: vec![block(0, 4, 0x58); MAX_RBC_REFERENCES_PER_FIELD + 1], + acknowledgments: UnboundedAckFields { + intersection: Some(0), + extra_references: Vec::new(), + }, + meta_creation_time_ns: base.meta_creation_time_ns, + transactions_commitment: base.transactions_commitment, + }; + let bytes = bincode::serialize(&oversized_parent_header).unwrap(); + assert!(bincode::deserialize::(&bytes).is_err()); + + let oversized_extra_header = UnboundedHeader { + reference: base.reference, + block_references: base.block_references, + acknowledgments: UnboundedAckFields { + intersection: Some(0), + extra_references: vec![block(0, 4, 0x58); MAX_RBC_REFERENCES_PER_FIELD + 1], + }, + meta_creation_time_ns: base.meta_creation_time_ns, + transactions_commitment: base.transactions_commitment, + }; + let bytes = bincode::serialize(&oversized_extra_header).unwrap(); + assert!(bincode::deserialize::(&bytes).is_err()); + } + + #[test] + fn canonical_header_content_size_enforces_the_four_mib_boundary() { + let references = |count: usize, round: RoundNumber, domain: u8| { + (0..count) + .map(|index| { + let mut digest = [0; 32]; + digest[0] = domain; + digest[1..9].copy_from_slice(&(index as u64).to_be_bytes()); + BlockReference { + authority: index as AuthorityIndex % 4, + round, + digest: BlockDigest::from(digest), + } + }) + .collect::>() + }; + let maximum_total_references = (MAX_RBC_HEADER_CONTENT_SIZE + - RBC_HEADER_FIXED_CONTENT_SIZE) + / RBC_BLOCK_REFERENCE_SIZE; + let parent_count = maximum_total_references / 2; + let acknowledgment_count = maximum_total_references - parent_count; + let accepted = RbcCanonicalHeader::try_new( + 0, + 5, + references(parent_count, 4, 0x01), + references(acknowledgment_count, 3, 0x02), + 0, + TransactionsCommitment::default(), + ) + .unwrap(); + assert_eq!( + accepted + .encoded_content_size(accepted.acknowledgment_references().len()) + .unwrap(), + MAX_RBC_HEADER_CONTENT_SIZE - 32 + ); + + let mut too_large_acknowledgments = accepted.acknowledgment_references(); + too_large_acknowledgments.push(block(0, 3, 0x03)); + assert_eq!( + RbcCanonicalHeader::try_new( + accepted.reference.authority, + accepted.reference.round, + accepted.block_references.clone(), + too_large_acknowledgments, + accepted.meta_creation_time_ns, + accepted.transactions_commitment, + ), + Err(RbcError::HeaderContentTooLarge) + ); + } + + #[test] + fn committee_id_is_stable_and_configuration_sensitive() { + let committee = Committee::new_test(vec![1, 2, 3, 4]); + let same_committee: Committee = + serde_yaml::from_str(&serde_yaml::to_string(&*committee).unwrap()).unwrap(); + let changed_stake = Committee::new_test(vec![1, 2, 3, 5]); + + let id = RbcCommitteeId::derive(&committee).unwrap(); + assert_eq!( + hex::encode(id.0), + "b64d92de81940c0965e6b8abc7b0b8ff409e3399343e6206d26be20f768fe970" + ); + assert_eq!(id, RbcCommitteeId::derive(&same_committee).unwrap()); + assert_ne!(id, RbcCommitteeId::derive(&changed_stake).unwrap()); + } + + #[test] + fn committee_id_encodes_authority_indices_above_255() { + let committee = Committee::new_test(vec![1; 300]); + let id = RbcCommitteeId::derive(&committee).unwrap(); + let round_trip: Committee = + serde_yaml::from_str(&serde_yaml::to_string(&*committee).unwrap()).unwrap(); + + assert_eq!(id, RbcCommitteeId::derive(&round_trip).unwrap()); + assert_ne!( + id, + RbcCommitteeId::derive(&Committee::new_test(vec![1; 299])).unwrap() + ); + } + + #[test] + fn oversized_deserialized_committee_is_rejected_before_authority_set_use() { + let committee = Committee::new_test(vec![1; MAX_COMMITTEE_SIZE as usize]); + let mut value = serde_yaml::to_value(committee.as_ref()).unwrap(); + let authorities = value + .as_mapping_mut() + .unwrap() + .get_mut(serde_yaml::Value::String("authorities".to_owned())) + .unwrap() + .as_sequence_mut() + .unwrap(); + authorities.push(authorities[0].clone()); + let oversized = Arc::new(serde_yaml::from_value::(value).unwrap()); + + assert_eq!(oversized.len(), MAX_COMMITTEE_SIZE as usize + 1); + let error = StarfishRbcKernel::new( + oversized, + 0, + instance(TEST_INSTANCE_BYTE), + BlockAuthenticationScheme::Ed25519, + Arc::new(Vec::new()), + 0, + ) + .err() + .unwrap(); + assert_eq!( + error, + RbcError::CommitteeTooLarge(MAX_COMMITTEE_SIZE as usize + 1) + ); + } + + #[test] + fn inconsistent_deserialized_threshold_is_rejected_and_changes_id() { + let committee = Committee::new_test(vec![1, 1, 1, 1]); + let yaml = serde_yaml::to_string(&*committee).unwrap(); + let tampered_yaml = yaml.replacen("validity_threshold: 1", "validity_threshold: 2", 1); + assert_ne!(yaml, tampered_yaml); + let tampered: Committee = serde_yaml::from_str(&tampered_yaml).unwrap(); + + assert_ne!( + RbcCommitteeId::derive(&committee).unwrap(), + RbcCommitteeId::derive(&tampered).unwrap() + ); + let error = StarfishRbcKernel::new( + Arc::new(tampered), + 0, + instance(TEST_INSTANCE_BYTE), + BlockAuthenticationScheme::Ed25519, + Arc::new(mac_keyrings_for_test(4)[0].clone()), + 0, + ) + .err() + .unwrap(); + assert!(matches!(error, RbcError::InvalidValidityThreshold { .. })); + } + + #[test] + fn protocol_instance_and_keyring_are_validated() { + assert_eq!( + RbcProtocolInstanceId::new([0; 32]), + Err(RbcError::ZeroProtocolInstance) + ); + let encoded = bincode::serialize(&[0u8; PROTOCOL_INSTANCE_SIZE]).unwrap(); + assert!(bincode::deserialize::(&encoded).is_err()); + let expected = instance(TEST_INSTANCE_BYTE); + assert_eq!( + bincode::deserialize::(&bincode::serialize(&expected).unwrap()) + .unwrap(), + expected + ); + let committee = Committee::new_test(vec![1; 4]); + let error = StarfishRbcKernel::new( + committee, + 0, + instance(TEST_INSTANCE_BYTE), + BlockAuthenticationScheme::Ed25519, + Arc::new(Vec::new()), + 0, + ) + .err() + .unwrap(); + assert_eq!( + error, + RbcError::InvalidKeyringLength { + expected: 4, + actual: 0, + } + ); + } + + #[test] + fn initial_mac_is_recipient_specific_and_direct_author_bound() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let author = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::MacVector, + ); + let recipient = kernel( + Arc::clone(&committee), + &keyrings, + 1, + BlockAuthenticationScheme::MacVector, + ); + let other_recipient = kernel( + Arc::clone(&committee), + &keyrings, + 2, + BlockAuthenticationScheme::MacVector, + ); + let block_ref = block(0, 7, 0x44); + let tag = author + .make_initial_mac_tag_for_reference(block_ref, 1) + .unwrap(); + + recipient + .verify_initial_mac_tag(0, block_ref, &tag) + .unwrap(); + assert_eq!( + other_recipient.verify_initial_mac_tag(0, block_ref, &tag), + Err(RbcError::InvalidInitialTag) + ); + assert!(matches!( + recipient.verify_initial_mac_tag(2, block_ref, &tag), + Err(RbcError::InitialAuthorMismatch { .. }) + )); + } + + #[test] + fn signature_digest_binds_context_scheme_and_reference() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let ed25519 = kernel_with_instance( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + 0x11, + ); + let ml_dsa = kernel_with_instance( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::MlDsa44, + 0x11, + ); + let other_instance = kernel_with_instance( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + 0x22, + ); + let mac = kernel( + committee, + &keyrings, + 0, + BlockAuthenticationScheme::MacVector, + ); + let first = block(0, 9, 0x10); + let second = block(0, 9, 0x11); + + assert_ne!( + ed25519.initial_signature_digest(first).unwrap(), + ed25519.initial_signature_digest(second).unwrap() + ); + assert_ne!( + ed25519.initial_signature_digest(first).unwrap(), + ml_dsa.initial_signature_digest(first).unwrap() + ); + assert_ne!( + ed25519.initial_signature_digest(first).unwrap(), + other_instance.initial_signature_digest(first).unwrap() + ); + assert_eq!( + mac.initial_signature_digest(first), + Err(RbcError::InitialSignatureRequiresSignatureAuthentication) + ); + } + + #[test] + fn typed_initial_proofs_gate_echo_for_every_authentication_scheme() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let canonical = valid_canonical_header(0, 5, 0x20); + let block_ref = canonical.reference(); + + for authentication in [ + BlockAuthenticationScheme::Ed25519, + BlockAuthenticationScheme::MlDsa44, + BlockAuthenticationScheme::MlDsa65, + BlockAuthenticationScheme::MacVector, + ] { + let mut receiver = kernel(Arc::clone(&committee), &keyrings, 1, authentication); + let proof = match authentication { + BlockAuthenticationScheme::Ed25519 => { + let digest = receiver.initial_signature_digest(block_ref).unwrap(); + RbcInitialProof::Ed25519(dummy_signer().sign_digest(&digest)) + } + BlockAuthenticationScheme::MlDsa44 => { + let digest = + BlockDigest::from(receiver.initial_signature_digest(block_ref).unwrap()); + RbcInitialProof::MlDsa44(dummy_ml_dsa_44_signer().sign_digest(&digest)) + } + BlockAuthenticationScheme::MlDsa65 => { + let digest = + BlockDigest::from(receiver.initial_signature_digest(block_ref).unwrap()); + RbcInitialProof::MlDsa65(dummy_ml_dsa_65_signer().sign_digest(&digest)) + } + BlockAuthenticationScheme::MacVector => { + let author = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::MacVector, + ); + RbcInitialProof::Mac( + author + .make_initial_mac_tag_for_reference(block_ref, 1) + .unwrap(), + ) + } + }; + let outcome = receiver + .accept_direct_initial_header(0, canonical.clone(), &proof) + .unwrap(); + assert!(matches!(outcome, + RbcInitialHeaderOutcome::Authenticated { effects } + if matches!(effects.as_slice(), [RbcEffect::MulticastPhase { + phase: RbcPhase::Echo, + .. + }]) + )); + } + } + + #[test] + fn invalid_initial_proof_still_allows_content_recovery_without_echo() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let canonical = valid_canonical_header(0, 5, 0x25); + let block_ref = canonical.reference(); + let mut receiver = kernel(committee, &keyrings, 1, BlockAuthenticationScheme::Ed25519); + let invalid = RbcInitialProof::Ed25519(SignatureBytes::default()); + + let outcome = receiver + .accept_direct_initial_header(0, canonical, &invalid) + .unwrap(); + assert!(matches!( + outcome, + RbcInitialHeaderOutcome::StagedUnauthenticated { + effects, + error: RbcError::InvalidInitialProof, + } if effects.is_empty() + )); + let slot = receiver.slot(&block_ref).unwrap(); + assert_eq!(slot.echoed, None); + assert_eq!( + slot.candidates + .get(&block_ref) + .and_then(|candidate| candidate.header.as_ref()) + .map(PinnedRbcHeader::reference), + Some(block_ref) + ); + + assert!( + receiver + .accept_recovered_header(valid_canonical_header(0, 5, 0x25)) + .unwrap() + .is_empty() + ); + + assert_eq!( + RbcInitialProof::from_block_authentication(&BlockAuthentication::MacVector(vec![])), + Err(RbcError::InvalidInitialProof) + ); + } + + #[test] + fn pinned_headers_and_echo_capabilities_are_kernel_bound() { + let committee_a = Committee::new_test(vec![1; 4]); + let committee_b = Committee::new_test(vec![1, 1, 1, 10]); + let keyrings = mac_keyrings_for_test(4); + let canonical = valid_canonical_header(0, 5, 0xA8); + let block_ref = canonical.reference(); + let pin_a = PinnedRbcHeader::validate(canonical.clone(), &committee_a).unwrap(); + assert_eq!( + PinnedRbcHeader::validate(canonical.clone(), &committee_b), + Err(RbcError::InvalidThresholdClock) + ); + + let mut committee_b_kernel = kernel( + committee_b, + &keyrings, + 1, + BlockAuthenticationScheme::Ed25519, + ); + assert_eq!( + committee_b_kernel.note_header_available(pin_a.clone()), + Err(RbcError::PinnedHeaderCommitteeMismatch) + ); + assert!(committee_b_kernel.slots.is_empty()); + + let author = kernel( + Arc::clone(&committee_a), + &keyrings, + 0, + BlockAuthenticationScheme::MacVector, + ); + let receiver_one = kernel( + Arc::clone(&committee_a), + &keyrings, + 1, + BlockAuthenticationScheme::MacVector, + ); + let proof = RbcInitialProof::Mac( + author + .make_initial_mac_tag_for_reference(block_ref, 1) + .unwrap(), + ); + let eligible_for_one = receiver_one + .direct_initial_header(0, pin_a.clone(), &proof) + .unwrap(); + let mut receiver_two = kernel( + Arc::clone(&committee_a), + &keyrings, + 2, + BlockAuthenticationScheme::MacVector, + ); + assert!(matches!( + receiver_two.accept_direct_initial_header(1, canonical, &proof), + Err(RbcError::InitialAuthorMismatch { .. }) + )); + assert!(receiver_two.slots.is_empty()); + assert_eq!( + receiver_two.accept_initial_header(eligible_for_one), + Err(RbcError::EchoCapabilityContextMismatch) + ); + assert!(receiver_two.slots.is_empty()); + + let eligible_for_instance = receiver_one + .direct_initial_header(0, pin_a, &proof) + .unwrap(); + let mut other_instance = kernel_with_instance( + committee_a, + &keyrings, + 1, + BlockAuthenticationScheme::MacVector, + 0xB6, + ); + assert_eq!( + other_instance.accept_initial_header(eligible_for_instance), + Err(RbcError::EchoCapabilityContextMismatch) + ); + assert!(other_instance.slots.is_empty()); + } + + #[test] + fn invalid_initial_proof_preserves_ready_effect_unblocked_by_staging() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let canonical = valid_canonical_header(0, 7, 0xB7); + let block_ref = canonical.reference(); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + + for sender in 0..3 { + let message = phase_message( + Arc::clone(&committee), + &keyrings, + sender, + 3, + RbcPhase::Echo, + block_ref, + ); + receiver.handle_phase(sender, message).unwrap(); + } + let outcome = receiver + .accept_direct_initial_header( + 0, + canonical, + &RbcInitialProof::Ed25519(SignatureBytes::default()), + ) + .unwrap(); + assert!(matches!( + outcome, + RbcInitialHeaderOutcome::StagedUnauthenticated { + effects, + error: RbcError::InvalidInitialProof, + } if effects == vec![RbcEffect::MulticastPhase { + phase: RbcPhase::Ready, + block_ref, + }] + )); + let slot = receiver.slot(&block_ref).unwrap(); + assert_eq!(slot.echoed, None); + assert_eq!(slot.readied, Some(block_ref)); + assert!(slot.candidates[&block_ref].header.is_some()); + } + + #[test] + fn local_initial_constructor_binds_the_local_author_and_content() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let mut kernel = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + ); + let template = valid_canonical_header(1, 5, 0x27); + + let local = kernel + .start_local_initial_header( + template.reference.round, + template.block_references.clone(), + template.acknowledgment_references(), + template.meta_creation_time_ns, + template.transactions_commitment, + ) + .unwrap(); + assert!(kernel.make_local_initial_signature_digest(&local).is_ok()); + let other_instance = kernel_with_instance( + committee, + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + 0xB8, + ); + assert_eq!( + other_instance.make_local_initial_signature_digest(&local), + Err(RbcError::LocalInitialContextMismatch) + ); + assert!(matches!( + kernel.start_local_initial_header( + template.reference.round, + template.block_references.clone(), + template.acknowledgment_references(), + template.meta_creation_time_ns + 1, + template.transactions_commitment, + ), + Err(RbcError::ConflictingInitialHeader { .. }) + )); + let (pinned, effects) = local.into_parts(); + assert_eq!(pinned.reference().authority, 0); + assert_ne!(pinned.reference(), template.reference()); + assert!(matches!( + effects.as_slice(), + [RbcEffect::MulticastPhase { + phase: RbcPhase::Echo, + .. + }] + )); + + assert!(matches!( + kernel.start_local_initial_header( + 6, + template.block_references[..2].to_vec(), + Vec::new(), + template.meta_creation_time_ns, + template.transactions_commitment, + ), + Err(RbcError::InvalidThresholdClock) + )); + } + + #[test] + fn phase_messages_are_specialized_for_each_recipient() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let mut sender = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + ); + let block_ref = block(0, 3, 0x21); + authorize_echo(&mut sender, block_ref); + let messages: Vec<_> = (1..4) + .map(|recipient| { + sender + .make_phase_message(RbcPhase::Echo, block_ref, recipient) + .unwrap() + }) + .collect(); + assert_eq!( + hex::encode(messages[0].tag.as_ref()), + "4a7a795ced01bb5fa2856de4b6664947e6564c8bc250c81a3073735346febf58" + ); + assert_eq!( + hex::encode(bincode::serialize(&messages[0]).unwrap()), + concat!( + "030000000000200000000000000021212121212121212121212121212121212121212121", + "21212121212121212121000001000000000020000000000000004a7a795ced01bb5fa2", + "856de4b6664947e6564c8bc250c81a3073735346febf58" + ) + ); + let distinct_tags: HashSet<_> = messages.iter().map(|message| message.tag).collect(); + assert_eq!(distinct_tags.len(), 3); + + for message in messages { + let recipient = message.recipient; + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + recipient, + BlockAuthenticationScheme::Ed25519, + ); + assert!(receiver.handle_phase(0, message).is_ok()); + } + } + + #[test] + fn phase_message_materialization_requires_authorized_local_state() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let first = block(0, 3, 0x23); + let conflicting = block(0, 3, 0x24); + let mut sender = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + ); + + assert!(matches!( + sender.make_phase_message(RbcPhase::Echo, first, 1), + Err(RbcError::PhaseNotAuthorized { .. }) + )); + sender + .note_header_available(pinned_header(&committee, first)) + .unwrap(); + assert!(matches!( + sender.make_phase_message(RbcPhase::Echo, first, 1), + Err(RbcError::PhaseNotAuthorized { .. }) + )); + + assert!(matches!( + sender.authorize_echo(first).unwrap().as_slice(), + [RbcEffect::MulticastPhase { + phase: RbcPhase::Echo, + .. + }] + )); + let first_message = sender.make_phase_message(RbcPhase::Echo, first, 1).unwrap(); + assert_eq!( + first_message, + sender.make_phase_message(RbcPhase::Echo, first, 1).unwrap() + ); + + sender + .note_header_available(pinned_header(&committee, conflicting)) + .unwrap(); + assert!(sender.authorize_echo(conflicting).unwrap().is_empty()); + assert!(matches!( + sender.make_phase_message(RbcPhase::Echo, conflicting, 1), + Err(RbcError::PhaseNotAuthorized { .. }) + )); + assert!(matches!( + sender.make_phase_message(RbcPhase::Ready, first, 1), + Err(RbcError::PhaseNotAuthorized { .. }) + )); + + for peer in [1, 2] { + let message = phase_message( + Arc::clone(&committee), + &keyrings, + peer, + 0, + RbcPhase::Echo, + first, + ); + sender.handle_phase(peer, message).unwrap(); + } + assert!(sender.make_phase_message(RbcPhase::Ready, first, 1).is_ok()); + } + + #[test] + fn phase_macs_work_under_every_initial_authentication_mode() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let block_ref = block(0, 3, 0x22); + + for authentication in [ + BlockAuthenticationScheme::Ed25519, + BlockAuthenticationScheme::MlDsa44, + BlockAuthenticationScheme::MlDsa65, + BlockAuthenticationScheme::MacVector, + ] { + let mut sender = kernel(Arc::clone(&committee), &keyrings, 0, authentication); + let mut receiver = kernel(Arc::clone(&committee), &keyrings, 1, authentication); + authorize_echo(&mut sender, block_ref); + let message = sender + .make_phase_message(RbcPhase::Echo, block_ref, 1) + .unwrap(); + assert!(receiver.handle_phase(0, message).is_ok()); + } + } + + #[test] + fn every_authenticated_phase_field_and_direct_peer_are_checked() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let mut sender = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + ); + let block_ref = block(0, 5, 0x31); + authorize_echo(&mut sender, block_ref); + let valid = sender + .make_phase_message(RbcPhase::Echo, block_ref, 1) + .unwrap(); + + let mut wrong_phase = valid.clone(); + wrong_phase.phase = RbcPhase::Ready; + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 1, + BlockAuthenticationScheme::Ed25519, + ); + assert_eq!( + receiver.handle_phase(0, wrong_phase), + Err(RbcError::InvalidPhaseTag) + ); + assert!(receiver.slots.is_empty()); + + let mut wrong_digest = valid.clone(); + wrong_digest.block_ref.digest = BlockDigest::from([0x32; 32]); + assert_eq!( + receiver.handle_phase(0, wrong_digest), + Err(RbcError::InvalidPhaseTag) + ); + assert!(receiver.slots.is_empty()); + + let mut wrong_author = valid.clone(); + wrong_author.block_ref.authority = 1; + assert_eq!( + receiver.handle_phase(0, wrong_author), + Err(RbcError::InvalidPhaseTag) + ); + assert!(receiver.slots.is_empty()); + + let mut wrong_round = valid.clone(); + wrong_round.block_ref.round += 1; + assert_eq!( + receiver.handle_phase(0, wrong_round), + Err(RbcError::InvalidPhaseTag) + ); + assert!(receiver.slots.is_empty()); + + let mut wrong_sender = valid.clone(); + wrong_sender.sender = 2; + assert_eq!( + receiver.handle_phase(2, wrong_sender), + Err(RbcError::InvalidPhaseTag) + ); + assert!(receiver.slots.is_empty()); + + assert!(matches!( + receiver.handle_phase(2, valid.clone()), + Err(RbcError::SenderPeerMismatch { .. }) + )); + assert!(receiver.slots.is_empty()); + + let mut wrong_recipient = valid.clone(); + wrong_recipient.recipient = 2; + assert!(matches!( + receiver.handle_phase(0, wrong_recipient), + Err(RbcError::WrongRecipient { .. }) + )); + assert!(receiver.slots.is_empty()); + + let mut other_instance = kernel_with_instance( + Arc::clone(&committee), + &keyrings, + 1, + BlockAuthenticationScheme::Ed25519, + 0xBB, + ); + assert_eq!( + other_instance.handle_phase(0, valid.clone()), + Err(RbcError::InvalidPhaseTag) + ); + assert!(other_instance.slots.is_empty()); + + let mut other_scheme = kernel_with_instance( + Arc::clone(&committee), + &keyrings, + 1, + BlockAuthenticationScheme::MlDsa44, + TEST_INSTANCE_BYTE, + ); + assert_eq!( + other_scheme.handle_phase(0, valid.clone()), + Err(RbcError::InvalidPhaseTag) + ); + assert!(other_scheme.slots.is_empty()); + + let changed_committee = Committee::new_test(vec![2, 1, 1, 1]); + let mut other_committee = kernel( + changed_committee, + &keyrings, + 1, + BlockAuthenticationScheme::Ed25519, + ); + assert_eq!( + other_committee.handle_phase(0, valid), + Err(RbcError::InvalidPhaseTag) + ); + assert!(other_committee.slots.is_empty()); + } + + #[test] + fn initial_mac_cannot_be_substituted_for_phase_mac() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let mut sender = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::MacVector, + ); + let mut receiver = kernel( + committee, + &keyrings, + 1, + BlockAuthenticationScheme::MacVector, + ); + let block_ref = block(0, 5, 0x41); + authorize_echo(&mut sender, block_ref); + let mut message = sender + .make_phase_message(RbcPhase::Echo, block_ref, 1) + .unwrap(); + message.tag = sender + .make_initial_mac_tag_for_reference(block_ref, 1) + .unwrap(); + + assert_eq!( + receiver.handle_phase(0, message), + Err(RbcError::InvalidPhaseTag) + ); + assert!(receiver.slots.is_empty()); + } + + #[test] + fn truncated_phase_message_fails_deserialization() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let mut sender = kernel(committee, &keyrings, 0, BlockAuthenticationScheme::Ed25519); + let block_ref = block(0, 5, 0x43); + authorize_echo(&mut sender, block_ref); + let message = sender + .make_phase_message(RbcPhase::Echo, block_ref, 1) + .unwrap(); + let mut encoded = bincode::serialize(&message).unwrap(); + encoded.pop(); + + assert!(bincode::deserialize::(&encoded).is_err()); + } + + #[test] + fn symmetric_key_cannot_reflect_message_direction() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let mut sender = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + ); + let block_ref = block(0, 5, 0x42); + authorize_echo(&mut sender, block_ref); + let mut reflected = sender + .make_phase_message(RbcPhase::Echo, block_ref, 1) + .unwrap(); + reflected.sender = 1; + reflected.recipient = 0; + let mut receiver = kernel(committee, &keyrings, 0, BlockAuthenticationScheme::Ed25519); + + assert_eq!( + receiver.handle_phase(1, reflected), + Err(RbcError::InvalidPhaseTag) + ); + assert!(receiver.slots.is_empty()); + } + + #[test] + fn echo_quorum_without_header_latches_fetch_then_ready() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let block_ref = block(0, 4, 0x51); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + + for sender in 0..2 { + let message = phase_message( + Arc::clone(&committee), + &keyrings, + sender, + 3, + RbcPhase::Echo, + block_ref, + ); + assert!(receiver.handle_phase(sender, message).unwrap().is_empty()); + } + let message = phase_message( + Arc::clone(&committee), + &keyrings, + 2, + 3, + RbcPhase::Echo, + block_ref, + ); + let effects = receiver.handle_phase(2, message).unwrap(); + let mut expected_holders = AuthoritySet::default(); + expected_holders.insert(0); + expected_holders.insert(1); + expected_holders.insert(2); + assert_eq!( + effects, + vec![RbcEffect::NeedHeader { + block_ref, + holders: expected_holders, + }] + ); + assert_eq!( + holder_vec(receiver.header_holders(&block_ref)), + vec![0, 1, 2] + ); + + assert_eq!( + receiver + .note_header_available(pinned_header(&committee, block_ref)) + .unwrap(), + vec![RbcEffect::MulticastPhase { + phase: RbcPhase::Ready, + block_ref, + }] + ); + } + + #[test] + fn ready_validity_without_header_can_ready_and_deliver_after_fetch() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let block_ref = block(0, 4, 0x52); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + + let first = phase_message( + Arc::clone(&committee), + &keyrings, + 0, + 3, + RbcPhase::Ready, + block_ref, + ); + assert!(receiver.handle_phase(0, first).unwrap().is_empty()); + let second = phase_message( + Arc::clone(&committee), + &keyrings, + 1, + 3, + RbcPhase::Ready, + block_ref, + ); + assert!(matches!( + receiver.handle_phase(1, second).unwrap().as_slice(), + [RbcEffect::NeedHeader { .. }] + )); + + assert_eq!( + receiver + .note_header_available(pinned_header(&committee, block_ref)) + .unwrap(), + vec![ + RbcEffect::MulticastPhase { + phase: RbcPhase::Ready, + block_ref, + }, + RbcEffect::Deliver(pinned_header(&committee, block_ref)), + ] + ); + } + + #[test] + fn header_fetch_can_retry_and_reemits_when_holder_set_grows() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let block_ref = block(0, 4, 0x54); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + + let first = phase_message( + Arc::clone(&committee), + &keyrings, + 0, + 3, + RbcPhase::Ready, + block_ref, + ); + assert!(receiver.handle_phase(0, first).unwrap().is_empty()); + let second = phase_message( + Arc::clone(&committee), + &keyrings, + 1, + 3, + RbcPhase::Ready, + block_ref, + ); + let initial_request = receiver.handle_phase(1, second).unwrap(); + assert_eq!( + receiver.retry_header_request(block_ref).unwrap(), + initial_request.first().cloned() + ); + + let third = phase_message( + Arc::clone(&committee), + &keyrings, + 2, + 3, + RbcPhase::Ready, + block_ref, + ); + let expanded_request = receiver.handle_phase(2, third).unwrap(); + assert!(matches!( + expanded_request.as_slice(), + [RbcEffect::NeedHeader { holders, .. }] if holder_vec(*holders) == vec![0, 1, 2] + )); + assert_eq!( + receiver.retry_header_request(block_ref).unwrap(), + expanded_request.first().cloned() + ); + + receiver + .note_header_available(pinned_header(&committee, block_ref)) + .unwrap(); + assert_eq!(receiver.retry_header_request(block_ref).unwrap(), None); + } + + #[test] + fn ready_quorum_never_delivers_before_header_arrives() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let block_ref = block(0, 4, 0x53); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + + for sender in 0..3 { + let message = phase_message( + Arc::clone(&committee), + &keyrings, + sender, + 3, + RbcPhase::Ready, + block_ref, + ); + receiver.handle_phase(sender, message).unwrap(); + } + assert_eq!(receiver.slot(&block_ref).unwrap().delivered, None); + + assert_eq!( + receiver + .note_header_available(pinned_header(&committee, block_ref)) + .unwrap(), + vec![ + RbcEffect::MulticastPhase { + phase: RbcPhase::Ready, + block_ref, + }, + RbcEffect::Deliver(pinned_header(&committee, block_ref)), + ] + ); + } + + #[test] + fn duplicates_do_not_inflate_phase_stake() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let block_ref = block(0, 6, 0x61); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + receiver + .note_header_available(pinned_header(&committee, block_ref)) + .unwrap(); + + for sender in [0, 0, 1] { + let message = phase_message( + Arc::clone(&committee), + &keyrings, + sender, + 3, + RbcPhase::Echo, + block_ref, + ); + assert!(receiver.handle_phase(sender, message).unwrap().is_empty()); + } + let message = phase_message( + Arc::clone(&committee), + &keyrings, + 2, + 3, + RbcPhase::Echo, + block_ref, + ); + assert!(matches!( + receiver.handle_phase(2, message).unwrap().as_slice(), + [RbcEffect::MulticastPhase { + phase: RbcPhase::Ready, + .. + }] + )); + } + + #[test] + fn slot_global_echo_does_not_first_seen_lock_other_candidate() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let first = block(0, 8, 0x71); + let quorum_candidate = block(0, 8, 0x72); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 0, + BlockAuthenticationScheme::Ed25519, + ); + receiver + .note_header_available(pinned_header(&committee, first)) + .unwrap(); + assert!(matches!( + receiver.authorize_echo(first).unwrap().as_slice(), + [RbcEffect::MulticastPhase { + phase: RbcPhase::Echo, + .. + }] + )); + receiver + .note_header_available(pinned_header(&committee, quorum_candidate)) + .unwrap(); + assert!( + receiver + .authorize_echo(quorum_candidate) + .unwrap() + .is_empty() + ); + + let mut last_effects = Vec::new(); + for sender in 1..4 { + let message = phase_message( + Arc::clone(&committee), + &keyrings, + sender, + 0, + RbcPhase::Echo, + quorum_candidate, + ); + last_effects = receiver.handle_phase(sender, message).unwrap(); + } + assert_eq!( + last_effects, + vec![RbcEffect::MulticastPhase { + phase: RbcPhase::Ready, + block_ref: quorum_candidate, + }] + ); + let slot = receiver.slot(&first).unwrap(); + assert_eq!(slot.echoed, Some(first)); + assert_eq!(slot.readied, Some(quorum_candidate)); + } + + #[test] + fn phase_equivocation_is_ignored_and_delivery_is_slot_global() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let first = block(0, 10, 0x81); + let second = block(0, 10, 0x82); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + receiver + .note_header_available(pinned_header(&committee, first)) + .unwrap(); + receiver + .note_header_available(pinned_header(&committee, second)) + .unwrap(); + + for candidate in [first, second] { + let message = phase_message( + Arc::clone(&committee), + &keyrings, + 0, + 3, + RbcPhase::Echo, + candidate, + ); + receiver.handle_phase(0, message).unwrap(); + } + assert!(receiver.candidate(&first).unwrap().echoes.votes.contains(0)); + assert!( + !receiver + .candidate(&second) + .unwrap() + .echoes + .votes + .contains(0) + ); + + for sender in 0..2 { + let message = phase_message( + Arc::clone(&committee), + &keyrings, + sender, + 3, + RbcPhase::Ready, + first, + ); + receiver.handle_phase(sender, message).unwrap(); + } + assert_eq!(receiver.slot(&first).unwrap().delivered, Some(first)); + + for sender in 0..3 { + let message = phase_message( + Arc::clone(&committee), + &keyrings, + sender, + 3, + RbcPhase::Ready, + second, + ); + assert!(receiver.handle_phase(sender, message).unwrap().is_empty()); + } + assert_eq!(receiver.slot(&second).unwrap().delivered, Some(first)); + } + + #[test] + fn four_kernel_split_initial_values_converge_on_at_most_one_delivery() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let first_header = valid_canonical_header(0, 12, 0xA1); + let conflicting_header = valid_canonical_header(0, 12, 0xA2); + let first = first_header.reference(); + let conflicting = conflicting_header.reference(); + let mut kernels: Vec<_> = (0..4) + .map(|authority| { + kernel( + Arc::clone(&committee), + &keyrings, + authority, + BlockAuthenticationScheme::Ed25519, + ) + }) + .collect(); + let mut header_stores = vec![AHashMap::new(); 4]; + + let mut initial_effects = Vec::new(); + let local = kernels[0] + .start_local_initial_header( + first_header.reference.round, + first_header.block_references.clone(), + first_header.acknowledgment_references(), + first_header.meta_creation_time_ns, + first_header.transactions_commitment, + ) + .unwrap(); + assert_eq!(local.header(), &first_header); + let first_signature_digest = kernels[0] + .make_local_initial_signature_digest(&local) + .unwrap(); + let first_proof = + RbcInitialProof::Ed25519(dummy_signer().sign_digest(&first_signature_digest)); + let (local_header, local_effects) = local.into_parts(); + header_stores[0].insert(first, local_header.header().clone()); + initial_effects.push((0, local_effects)); + + for authority in 1..3 { + let outcome = kernels[authority as usize] + .accept_direct_initial_header(0, first_header.clone(), &first_proof) + .unwrap(); + let RbcInitialHeaderOutcome::Authenticated { effects } = outcome else { + panic!("valid direct initial header must authenticate") + }; + header_stores[authority as usize].insert(first, first_header.clone()); + initial_effects.push((authority, effects)); + } + + let conflicting_digest = kernels[3].initial_signature_digest(conflicting).unwrap(); + let conflicting_proof = + RbcInitialProof::Ed25519(dummy_signer().sign_digest(&conflicting_digest)); + let outcome = kernels[3] + .accept_direct_initial_header(0, conflicting_header.clone(), &conflicting_proof) + .unwrap(); + let RbcInitialHeaderOutcome::Authenticated { effects } = outcome else { + panic!("valid conflicting author proof must authenticate at its recipient") + }; + header_stores[3].insert(conflicting, conflicting_header); + initial_effects.push((3, effects)); + + let (deliveries, recoveries) = + pump_phase_effects(&mut kernels, initial_effects, &mut header_stores); + assert!(deliveries.iter().all(|delivered| delivered == &[first])); + assert!( + deliveries + .iter() + .flatten() + .all(|delivered| *delivered != conflicting) + ); + assert!( + recoveries + .iter() + .any(|(requester, _, block_ref)| *requester == 3 && *block_ref == first) + ); + assert_eq!(kernels[3].slot(&first).unwrap().echoed, Some(conflicting)); + assert_eq!(kernels[3].slot(&first).unwrap().readied, Some(first)); + } + + #[test] + fn poisoned_initial_mac_does_not_block_rbc_totality() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let canonical = valid_canonical_header(0, 13, 0xA3); + let block_ref = canonical.reference(); + let mut kernels: Vec<_> = (0..4) + .map(|authority| { + kernel( + Arc::clone(&committee), + &keyrings, + authority, + BlockAuthenticationScheme::MacVector, + ) + }) + .collect(); + let mut header_stores = vec![AHashMap::new(); 4]; + + let local = kernels[0] + .start_local_initial_header( + canonical.reference.round, + canonical.block_references.clone(), + canonical.acknowledgment_references(), + canonical.meta_creation_time_ns, + canonical.transactions_commitment, + ) + .unwrap(); + let valid_for_one = kernels[0].make_local_initial_mac_tag(&local, 1).unwrap(); + let valid_for_two = kernels[0].make_local_initial_mac_tag(&local, 2).unwrap(); + let (local_header, local_effects) = local.into_parts(); + header_stores[0].insert(block_ref, local_header.header().clone()); + let mut initial_effects = vec![(0, local_effects)]; + for (authority, tag) in [(1, valid_for_one), (2, valid_for_two)] { + let outcome = kernels[authority as usize] + .accept_direct_initial_header(0, canonical.clone(), &RbcInitialProof::Mac(tag)) + .unwrap(); + let RbcInitialHeaderOutcome::Authenticated { effects } = outcome else { + panic!("recipient-specific author MAC must authenticate") + }; + header_stores[authority as usize].insert(block_ref, canonical.clone()); + initial_effects.push((authority, effects)); + } + + let poisoned = kernels[3] + .accept_direct_initial_header( + 0, + canonical.clone(), + &RbcInitialProof::Mac(valid_for_two), + ) + .unwrap(); + assert!(matches!( + poisoned, + RbcInitialHeaderOutcome::StagedUnauthenticated { + effects, + error: RbcError::InvalidInitialTag, + } if effects.is_empty() + )); + header_stores[3].insert(block_ref, canonical); + + let (deliveries, recoveries) = + pump_phase_effects(&mut kernels, initial_effects, &mut header_stores); + assert!(deliveries.iter().all(|delivered| delivered == &[block_ref])); + assert!(recoveries.is_empty()); + let recovered_slot = kernels[3].slot(&block_ref).unwrap(); + assert_eq!(recovered_slot.echoed, None); + assert_eq!(recovered_slot.readied, Some(block_ref)); + assert_eq!(recovered_slot.delivered, Some(block_ref)); + } + + #[test] + fn weighted_thresholds_and_author_echo_are_counted_exactly() { + let committee = Committee::new_test(vec![3, 2, 1, 1]); + assert_eq!(committee.validity_threshold(), 3); + assert_eq!(committee.quorum_threshold(), 5); + let keyrings = mac_keyrings_for_test(4); + let block_ref = block(3, 11, 0x91); + let mut receiver = kernel( + Arc::clone(&committee), + &keyrings, + 3, + BlockAuthenticationScheme::Ed25519, + ); + receiver + .note_header_available(pinned_header(&committee, block_ref)) + .unwrap(); + assert_eq!( + receiver.authorize_echo(block_ref).unwrap(), + vec![RbcEffect::MulticastPhase { + phase: RbcPhase::Echo, + block_ref, + }] + ); + + let high_stake = phase_message( + Arc::clone(&committee), + &keyrings, + 0, + 3, + RbcPhase::Echo, + block_ref, + ); + assert!(receiver.handle_phase(0, high_stake).unwrap().is_empty()); + let boundary = phase_message(committee, &keyrings, 2, 3, RbcPhase::Echo, block_ref); + assert!(matches!( + receiver.handle_phase(2, boundary).unwrap().as_slice(), + [RbcEffect::MulticastPhase { + phase: RbcPhase::Ready, + .. + }] + )); + } +} diff --git a/crates/starfish-core/src/starfish_rbc_service.rs b/crates/starfish-core/src/starfish_rbc_service.rs new file mode 100644 index 00000000..2b536e78 --- /dev/null +++ b/crates/starfish-core/src/starfish_rbc_service.rs @@ -0,0 +1,1582 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Single-owner async adapter around the Starfish-RBC kernel. +//! +//! The service deliberately owns header-recovery state independently of any +//! one network connection. Connection workers only attach their trusted peer +//! identity and forward messages into this actor; reconnects therefore cannot +//! discard a pending recovery attempt. + +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error, + fmt, + sync::Arc, + time::Duration, +}; + +use ahash::{AHashMap, AHashSet}; +use tokio::{ + sync::{mpsc, oneshot}, + task::JoinHandle, + time::MissedTickBehavior, +}; + +use crate::{ + committee::Committee, + crypto::{MacKey, MlDsa44Signer, MlDsa65Signer, Signer, TransactionsCommitment}, + network::NetworkMessage, + starfish_rbc::{ + PinnedRbcHeader, RbcCanonicalHeader, RbcEffect, RbcError, RbcHeaderProposal, + RbcInitialHeaderOutcome, RbcInitialProof, RbcLocalInitial, RbcPhase, RbcPhaseMessage, + RbcProtocolInstanceId, StarfishRbcKernel, + }, + types::{ + AuthorityIndex, AuthoritySet, BlockAuthenticationScheme, BlockDigest, BlockReference, + RoundNumber, StarfishRbcFieldsV3, StarfishRbcReferenceKindV3, StarfishRbcReferenceV3, + TimestampNs, TransactionData, + }, +}; + +const HEADER_REQUEST_FANOUT: usize = 2; + +/// Authentication material used only for the author's initial RBC proposal. +/// ECHO and READY always use the pairwise MAC keyring owned by the kernel. +#[derive(Clone)] +pub(crate) enum RbcInitialAuthenticator { + Ed25519(Signer), + MlDsa44(MlDsa44Signer), + MlDsa65(MlDsa65Signer), + Mac, +} + +impl RbcInitialAuthenticator { + fn scheme(&self) -> BlockAuthenticationScheme { + match self { + Self::Ed25519(_) => BlockAuthenticationScheme::Ed25519, + Self::MlDsa44(_) => BlockAuthenticationScheme::MlDsa44, + Self::MlDsa65(_) => BlockAuthenticationScheme::MlDsa65, + Self::Mac => BlockAuthenticationScheme::MacVector, + } + } +} + +/// Authentication-free inputs for atomically starting one local RBC slot. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RbcLocalHeader { + pub round: RoundNumber, + pub block_references: Vec, + pub acknowledgment_references: Vec, + pub meta_creation_time_ns: TimestampNs, + pub transactions_commitment: TransactionsCommitment, + pub starfish_rbc_v3: Option, +} + +impl RbcLocalHeader { + pub(crate) fn from_canonical(header: &RbcCanonicalHeader) -> Self { + Self { + round: header.reference().round, + block_references: header.block_references().to_vec(), + acknowledgment_references: header.acknowledgment_references(), + meta_creation_time_ns: header.meta_creation_time_ns(), + transactions_commitment: header.transactions_commitment(), + starfish_rbc_v3: header.starfish_rbc_v3().cloned(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum RbcServiceError { + Kernel(RbcError), + InitialAuthenticatorSchemeMismatch { + configured: BlockAuthenticationScheme, + supplied: BlockAuthenticationScheme, + }, + LocalAuthenticatorKeyMismatch(AuthorityIndex), + ZeroHeaderRetryInterval, + UnexpectedHeaderResponse(BlockReference), + HeaderResponseFromNonHolder { + block_ref: BlockReference, + peer: AuthorityIndex, + }, + ServiceStopped, +} + +impl From for RbcServiceError { + fn from(error: RbcError) -> Self { + Self::Kernel(error) + } +} + +impl fmt::Display for RbcServiceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Kernel(error) => error.fmt(formatter), + Self::InitialAuthenticatorSchemeMismatch { + configured, + supplied, + } => write!( + formatter, + "Starfish-RBC initial authenticator mismatch: configured {configured:?}, \ + supplied {supplied:?}" + ), + Self::LocalAuthenticatorKeyMismatch(authority) => write!( + formatter, + "Starfish-RBC initial authenticator key does not match authority {authority}" + ), + Self::ZeroHeaderRetryInterval => { + formatter.write_str("Starfish-RBC header retry interval must be nonzero") + } + Self::UnexpectedHeaderResponse(block_ref) => write!( + formatter, + "unexpected Starfish-RBC header response for {block_ref}" + ), + Self::HeaderResponseFromNonHolder { block_ref, peer } => write!( + formatter, + "Starfish-RBC header response for {block_ref} came from non-holder {peer}" + ), + Self::ServiceStopped => formatter.write_str("Starfish-RBC service stopped"), + } + } +} + +impl Error for RbcServiceError {} + +/// Events consumed by the network/core integration bridge. +#[derive(Debug)] +pub(crate) enum RbcServiceEvent { + Network { + recipient: AuthorityIndex, + message: NetworkMessage, + }, + HeaderStaged(PinnedRbcHeader), + TransactionPayloadStaged { + peer: AuthorityIndex, + header: PinnedRbcHeader, + transaction_data: Arc, + }, + Delivered(PinnedRbcHeader), + /// An irrevocable local phase statement waiting to be embedded in the + /// next ordinary Starfish block. + ReferenceReady(StarfishRbcReferenceV3), + Rejected { + peer: Option, + error: RbcServiceError, + }, +} + +enum RbcServiceMessage { + StartLocal { + header: RbcLocalHeader, + transaction_data: Option, + reply: oneshot::Sender>, + }, + DirectInitial { + peer: AuthorityIndex, + proposal: RbcHeaderProposal, + }, + Phase { + peer: AuthorityIndex, + message: RbcPhaseMessage, + }, + HeaderRequest { + peer: AuthorityIndex, + block_ref: BlockReference, + }, + HeaderResponse { + peer: AuthorityIndex, + header: RbcCanonicalHeader, + }, + PeerConnected(AuthorityIndex), + PeerDisconnected(AuthorityIndex), + #[allow(dead_code)] + AdvanceLocalRound { + round: RoundNumber, + reply: oneshot::Sender>, + }, + #[allow(dead_code)] + RetryHeaders(oneshot::Sender<()>), +} + +#[derive(Clone)] +pub(crate) struct RbcServiceHandle { + sender: mpsc::UnboundedSender, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RbcPhaseAuthorityV1 { + Direct, + EmbeddedSingleDag { echo_qc_fast_path: bool }, +} +impl RbcServiceHandle { + #[allow(dead_code)] + pub(crate) async fn start_local_header( + &self, + header: RbcLocalHeader, + ) -> Result { + self.start_local_header_with_payload(header, None).await + } + + #[allow(dead_code)] + pub(crate) async fn start_local_header_with_payload( + &self, + header: RbcLocalHeader, + transaction_data: Option, + ) -> Result { + let (reply, receiver) = oneshot::channel(); + self.send(RbcServiceMessage::StartLocal { + header, + transaction_data, + reply, + })?; + receiver + .await + .map_err(|_| RbcServiceError::ServiceStopped)? + } + + /// Synchronous entry point for the dedicated core thread. The reply is + /// completed only after the kernel has selected/pinned the slot and all + /// INIT/phase events have been enqueued, so legacy dissemination cannot + /// race ahead of local RBC authorization. + pub(crate) fn start_local_header_with_payload_blocking( + &self, + header: RbcLocalHeader, + transaction_data: Option, + ) -> Result { + let (reply, receiver) = oneshot::channel(); + self.send(RbcServiceMessage::StartLocal { + header, + transaction_data, + reply, + })?; + receiver + .blocking_recv() + .map_err(|_| RbcServiceError::ServiceStopped)? + } + + pub(crate) fn direct_initial( + &self, + peer: AuthorityIndex, + proposal: RbcHeaderProposal, + ) -> Result<(), RbcServiceError> { + self.send(RbcServiceMessage::DirectInitial { peer, proposal }) + } + + pub(crate) fn phase( + &self, + peer: AuthorityIndex, + message: RbcPhaseMessage, + ) -> Result<(), RbcServiceError> { + self.send(RbcServiceMessage::Phase { peer, message }) + } + + pub(crate) fn header_request( + &self, + peer: AuthorityIndex, + block_ref: BlockReference, + ) -> Result<(), RbcServiceError> { + self.send(RbcServiceMessage::HeaderRequest { peer, block_ref }) + } + + pub(crate) fn header_response( + &self, + peer: AuthorityIndex, + header: RbcCanonicalHeader, + ) -> Result<(), RbcServiceError> { + self.send(RbcServiceMessage::HeaderResponse { peer, header }) + } + + pub(crate) fn peer_connected(&self, peer: AuthorityIndex) -> Result<(), RbcServiceError> { + self.send(RbcServiceMessage::PeerConnected(peer)) + } + + pub(crate) fn peer_disconnected(&self, peer: AuthorityIndex) -> Result<(), RbcServiceError> { + self.send(RbcServiceMessage::PeerDisconnected(peer)) + } + + #[allow(dead_code)] + pub(crate) async fn advance_local_round( + &self, + round: RoundNumber, + ) -> Result<(), RbcServiceError> { + let (reply, receiver) = oneshot::channel(); + self.send(RbcServiceMessage::AdvanceLocalRound { round, reply })?; + receiver + .await + .map_err(|_| RbcServiceError::ServiceStopped)? + } + + /// Trigger a recovery wave immediately. Production also has an internal + /// periodic timer; this method is useful after topology changes and makes + /// retry behavior deterministic in tests. + #[allow(dead_code)] + pub(crate) async fn retry_headers(&self) -> Result<(), RbcServiceError> { + let (reply, receiver) = oneshot::channel(); + self.send(RbcServiceMessage::RetryHeaders(reply))?; + receiver.await.map_err(|_| RbcServiceError::ServiceStopped) + } + + fn send(&self, message: RbcServiceMessage) -> Result<(), RbcServiceError> { + self.sender + .send(message) + .map_err(|_| RbcServiceError::ServiceStopped) + } +} + +/// Start the single Starfish-RBC state-machine owner. +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +pub(crate) fn start_starfish_rbc_service( + committee: Arc, + own_authority: AuthorityIndex, + protocol_instance: RbcProtocolInstanceId, + initial_authentication: BlockAuthenticationScheme, + mac_keys: Arc>, + initial_authenticator: RbcInitialAuthenticator, + local_round: RoundNumber, + header_retry_interval: Duration, +) -> Result< + ( + RbcServiceHandle, + mpsc::UnboundedReceiver, + JoinHandle<()>, + ), + RbcServiceError, +> { + start_starfish_rbc_service_with_phase_authority( + committee, + own_authority, + protocol_instance, + initial_authentication, + mac_keys, + initial_authenticator, + local_round, + header_retry_interval, + RbcPhaseAuthorityV1::Direct, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn start_starfish_rbc_service_with_phase_authority( + committee: Arc, + own_authority: AuthorityIndex, + protocol_instance: RbcProtocolInstanceId, + initial_authentication: BlockAuthenticationScheme, + mac_keys: Arc>, + initial_authenticator: RbcInitialAuthenticator, + local_round: RoundNumber, + header_retry_interval: Duration, + phase_authority: RbcPhaseAuthorityV1, +) -> Result< + ( + RbcServiceHandle, + mpsc::UnboundedReceiver, + JoinHandle<()>, + ), + RbcServiceError, +> { + if header_retry_interval.is_zero() { + return Err(RbcServiceError::ZeroHeaderRetryInterval); + } + if initial_authenticator.scheme() != initial_authentication { + return Err(RbcServiceError::InitialAuthenticatorSchemeMismatch { + configured: initial_authentication, + supplied: initial_authenticator.scheme(), + }); + } + validate_local_authenticator(&committee, own_authority, &initial_authenticator)?; + + let echo_qc_fast_path = match phase_authority { + RbcPhaseAuthorityV1::EmbeddedSingleDag { echo_qc_fast_path } => echo_qc_fast_path, + RbcPhaseAuthorityV1::Direct => false, + }; + let kernel = StarfishRbcKernel::new_with_echo_qc_fast_path( + committee.clone(), + own_authority, + protocol_instance, + initial_authentication, + mac_keys, + local_round, + echo_qc_fast_path, + )?; + let (message_tx, message_rx) = mpsc::unbounded_channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let state = RbcServiceState { + committee, + own_authority, + initial_authenticator, + kernel, + events: event_tx, + connected_peers: AuthoritySet::default(), + pending_fetches: AHashMap::new(), + staged_notifications: AHashSet::new(), + retained_initials: BTreeMap::new(), + retained_phases: BTreeSet::new(), + phase_authority, + }; + let task = tokio::spawn(run_service(state, message_rx, header_retry_interval)); + Ok((RbcServiceHandle { sender: message_tx }, event_rx, task)) +} + +fn validate_local_authenticator( + committee: &Committee, + own_authority: AuthorityIndex, + authenticator: &RbcInitialAuthenticator, +) -> Result<(), RbcServiceError> { + let matches = match authenticator { + RbcInitialAuthenticator::Ed25519(signer) => committee + .get_public_key(own_authority) + .is_some_and(|public_key| public_key == &signer.public_key()), + RbcInitialAuthenticator::MlDsa44(signer) => committee + .get_ml_dsa_44_public_key(own_authority) + .is_some_and(|public_key| public_key == &signer.public_key()), + RbcInitialAuthenticator::MlDsa65(signer) => committee + .get_ml_dsa_65_public_key(own_authority) + .is_some_and(|public_key| public_key == &signer.public_key()), + RbcInitialAuthenticator::Mac => committee.known_authority(own_authority), + }; + if matches { + Ok(()) + } else { + Err(RbcServiceError::LocalAuthenticatorKeyMismatch( + own_authority, + )) + } +} + +async fn run_service( + mut state: RbcServiceState, + mut messages: mpsc::UnboundedReceiver, + header_retry_interval: Duration, +) { + let mut retry = tokio::time::interval(header_retry_interval); + retry.set_missed_tick_behavior(MissedTickBehavior::Skip); + retry.tick().await; + + loop { + tokio::select! { + maybe_message = messages.recv() => { + let Some(message) = maybe_message else { + break; + }; + state.process_message(message); + } + _ = retry.tick() => state.retry_pending_headers(), + } + } +} + +struct PendingHeaderFetch { + holders: AuthoritySet, + requested_from: AuthoritySet, +} + +struct RbcServiceState { + committee: Arc, + own_authority: AuthorityIndex, + initial_authenticator: RbcInitialAuthenticator, + kernel: StarfishRbcKernel, + events: mpsc::UnboundedSender, + connected_peers: AuthoritySet, + pending_fetches: AHashMap, + staged_notifications: AHashSet, + /// Recipient-specialized local proposals retained for replay after a + /// connection is replaced. Version one keeps these for the run. + retained_initials: BTreeMap<(BlockReference, AuthorityIndex), RbcHeaderProposal>, + /// Authorized local phase intents. Tags are rematerialized for the peer + /// on replay rather than retaining or cloning a tagged wire message. + retained_phases: BTreeSet<(BlockReference, RbcPhase)>, + phase_authority: RbcPhaseAuthorityV1, +} + +impl RbcServiceState { + fn process_message(&mut self, message: RbcServiceMessage) { + match message { + RbcServiceMessage::StartLocal { + header, + transaction_data, + reply, + } => { + let result = self.start_local_header(header, transaction_data); + let _ = reply.send(result); + } + RbcServiceMessage::DirectInitial { peer, proposal } => { + self.accept_direct_initial(peer, proposal); + } + RbcServiceMessage::Phase { peer, message } => { + match self.kernel.handle_phase(peer, message) { + Ok(effects) => self.process_effects(effects), + Err(error) => self.reject(Some(peer), error.into()), + } + } + RbcServiceMessage::HeaderRequest { peer, block_ref } => { + self.answer_header_request(peer, block_ref); + } + RbcServiceMessage::HeaderResponse { peer, header } => { + self.accept_header_response(peer, header); + } + RbcServiceMessage::PeerConnected(peer) => self.peer_connected(peer), + RbcServiceMessage::PeerDisconnected(peer) => self.peer_disconnected(peer), + RbcServiceMessage::AdvanceLocalRound { round, reply } => { + let result = self + .kernel + .advance_local_round(round) + .map_err(RbcServiceError::from); + let _ = reply.send(result); + } + RbcServiceMessage::RetryHeaders(reply) => { + self.retry_pending_headers(); + let _ = reply.send(()); + } + } + } + + fn start_local_header( + &mut self, + header: RbcLocalHeader, + transaction_data: Option, + ) -> Result { + self.kernel.advance_local_round(header.round)?; + let local = match header.starfish_rbc_v3 { + Some(rbc) => self.kernel.start_local_initial_header_with_fields( + header.round, + header.block_references, + header.acknowledgment_references, + header.meta_creation_time_ns, + header.transactions_commitment, + Some(rbc), + ), + None => self.kernel.start_local_initial_header( + header.round, + header.block_references, + header.acknowledgment_references, + header.meta_creation_time_ns, + header.transactions_commitment, + ), + }?; + let canonical = local.header().clone(); + let embedded_references = canonical.starfish_rbc_v3().cloned(); + let transaction_data = transaction_data.map(Arc::new); + let proposals = self.make_initial_proposals(&local, transaction_data); + let (pinned, effects) = local.into_parts(); + + self.notify_header_staged(pinned); + for (recipient, proposal) in proposals { + self.retained_initials + .insert((canonical.reference(), recipient), proposal.clone()); + self.send_network(recipient, NetworkMessage::RbcInitial(proposal)); + } + self.process_effects(effects); + self.process_embedded_references(self.own_authority, embedded_references); + Ok(canonical) + } + + fn make_initial_proposals( + &self, + local: &RbcLocalInitial, + transaction_data: Option>, + ) -> Vec<(AuthorityIndex, RbcHeaderProposal)> { + let header = local.header().clone(); + match &self.initial_authenticator { + RbcInitialAuthenticator::Ed25519(signer) => { + let digest = self + .kernel + .make_local_initial_signature_digest(local) + .expect("local RBC handle must remain selected"); + let proof = RbcInitialProof::Ed25519(signer.sign_digest(&digest)); + self.public_initial_proposals(header, proof, transaction_data) + } + RbcInitialAuthenticator::MlDsa44(signer) => { + let digest = self + .kernel + .make_local_initial_signature_digest(local) + .expect("local RBC handle must remain selected"); + let proof = + RbcInitialProof::MlDsa44(signer.sign_digest(&BlockDigest::from(digest))); + self.public_initial_proposals(header, proof, transaction_data) + } + RbcInitialAuthenticator::MlDsa65(signer) => { + let digest = self + .kernel + .make_local_initial_signature_digest(local) + .expect("local RBC handle must remain selected"); + let proof = + RbcInitialProof::MlDsa65(signer.sign_digest(&BlockDigest::from(digest))); + self.public_initial_proposals(header, proof, transaction_data) + } + RbcInitialAuthenticator::Mac => self + .committee + .authorities() + .filter(|recipient| *recipient != self.own_authority) + .map(|recipient| { + let tag = self + .kernel + .make_local_initial_mac_tag(local, recipient) + .expect("local RBC handle must remain selected"); + ( + recipient, + RbcHeaderProposal::with_transaction_data( + header.clone(), + RbcInitialProof::Mac(tag), + transaction_data.clone(), + ), + ) + }) + .collect(), + } + } + + fn public_initial_proposals( + &self, + header: RbcCanonicalHeader, + proof: RbcInitialProof, + transaction_data: Option>, + ) -> Vec<(AuthorityIndex, RbcHeaderProposal)> { + self.committee + .authorities() + .filter(|recipient| *recipient != self.own_authority) + .map(|recipient| { + ( + recipient, + RbcHeaderProposal::with_transaction_data( + header.clone(), + proof.clone(), + transaction_data.clone(), + ), + ) + }) + .collect() + } + + fn accept_direct_initial(&mut self, peer: AuthorityIndex, proposal: RbcHeaderProposal) { + let (header, proof, transaction_data) = proposal.into_parts(); + let block_ref = header.reference(); + let embedded_references = header.starfish_rbc_v3().cloned(); + match self + .kernel + .accept_direct_initial_header(peer, header, &proof) + { + Ok(RbcInitialHeaderOutcome::Authenticated { effects }) => { + let pinned = self.finish_header_staging(block_ref, Some(peer)); + self.notify_transaction_payload(peer, pinned, transaction_data); + self.process_effects(effects); + self.process_embedded_references(peer, embedded_references); + } + Ok(RbcInitialHeaderOutcome::StagedUnauthenticated { effects, error }) => { + let pinned = self.finish_header_staging(block_ref, Some(peer)); + self.notify_transaction_payload(peer, pinned, transaction_data); + self.process_effects(effects); + self.reject(Some(peer), error.into()); + } + Err(error) => self.reject(Some(peer), error.into()), + } + } + + fn answer_header_request(&mut self, peer: AuthorityIndex, block_ref: BlockReference) { + if !self.committee.known_authority(peer) { + self.reject(Some(peer), RbcError::UnknownAuthority(peer).into()); + return; + } + if peer == self.own_authority { + self.reject(Some(peer), RbcError::LoopbackPhase.into()); + return; + } + match self.kernel.pinned_header(block_ref) { + Ok(Some(header)) => self.send_network( + peer, + NetworkMessage::RbcHeaderResponse(header.header().clone()), + ), + Ok(None) => {} + Err(error) => self.reject(Some(peer), error.into()), + } + } + + fn accept_header_response(&mut self, peer: AuthorityIndex, header: RbcCanonicalHeader) { + let block_ref = header.reference(); + if !self.committee.known_authority(peer) { + self.reject(Some(peer), RbcError::UnknownAuthority(peer).into()); + return; + } + if peer == self.own_authority { + self.reject(Some(peer), RbcError::LoopbackPhase.into()); + return; + } + let Some(fetch) = self.pending_fetches.get(&block_ref) else { + // A quorum may answer the same content request concurrently. Once + // one response has pinned the exact header, later identical + // responses are benign rather than protocol rejections. + if self + .kernel + .pinned_header(block_ref) + .is_ok_and(|header| header.is_some()) + { + return; + } + self.reject( + Some(peer), + RbcServiceError::UnexpectedHeaderResponse(block_ref), + ); + return; + }; + if !fetch.holders.contains(peer) { + self.reject( + Some(peer), + RbcServiceError::HeaderResponseFromNonHolder { block_ref, peer }, + ); + return; + } + + match self.kernel.accept_recovered_header(header) { + Ok(effects) => { + self.finish_header_staging(block_ref, Some(peer)); + self.process_effects(effects); + } + Err(error) => self.reject(Some(peer), error.into()), + } + } + + fn finish_header_staging( + &mut self, + block_ref: BlockReference, + peer: Option, + ) -> Option { + match self.kernel.pinned_header(block_ref) { + Ok(Some(header)) => { + self.pending_fetches.remove(&block_ref); + self.notify_header_staged(header.clone()); + Some(header) + } + Ok(None) => { + self.reject(peer, RbcError::HeaderUnavailable(block_ref).into()); + None + } + Err(error) => { + self.reject(peer, error.into()); + None + } + } + } + + fn notify_transaction_payload( + &self, + peer: AuthorityIndex, + header: Option, + transaction_data: Option>, + ) { + if let (Some(header), Some(transaction_data)) = (header, transaction_data) { + let _ = self.events.send(RbcServiceEvent::TransactionPayloadStaged { + peer, + header, + transaction_data, + }); + } + } + + fn notify_header_staged(&mut self, header: PinnedRbcHeader) { + if self.staged_notifications.insert(header.reference()) { + let _ = self.events.send(RbcServiceEvent::HeaderStaged(header)); + } + } + + fn process_effects(&mut self, effects: Vec) { + for effect in effects { + match effect { + RbcEffect::MulticastPhase { phase, block_ref } => { + if matches!( + self.phase_authority, + RbcPhaseAuthorityV1::EmbeddedSingleDag { .. } + ) { + let kind = match phase { + RbcPhase::Echo => StarfishRbcReferenceKindV3::Echo, + RbcPhase::Ready => StarfishRbcReferenceKindV3::Ready, + }; + let _ = self.events.send(RbcServiceEvent::ReferenceReady( + StarfishRbcReferenceV3::new(kind, block_ref), + )); + continue; + } + self.retained_phases.insert((block_ref, phase)); + let recipients: Vec<_> = self + .committee + .authorities() + .filter(|recipient| *recipient != self.own_authority) + .collect(); + for recipient in recipients { + match self.kernel.make_phase_message(phase, block_ref, recipient) { + Ok(message) => { + self.send_network(recipient, NetworkMessage::RbcPhase(message)) + } + Err(error) => self.reject(None, error.into()), + } + } + } + RbcEffect::NeedHeader { block_ref, holders } => { + self.note_pending_fetch(block_ref, holders); + } + RbcEffect::Deliver(header) => { + self.pending_fetches.remove(&header.reference()); + let _ = self.events.send(RbcServiceEvent::Delivered(header)); + } + } + } + } + + fn process_embedded_references( + &mut self, + sender: AuthorityIndex, + references: Option, + ) { + if !matches!( + self.phase_authority, + RbcPhaseAuthorityV1::EmbeddedSingleDag { .. } + ) { + return; + } + let Some(references) = references else { + self.reject( + Some(sender), + RbcServiceError::Kernel(RbcError::InvalidSingleDagEvidence), + ); + return; + }; + for evidence in references.references() { + match self.kernel.handle_embedded_reference(sender, *evidence) { + Ok(effects) => self.process_effects(effects), + Err(error) => self.reject(Some(sender), error.into()), + } + } + } + + fn note_pending_fetch(&mut self, block_ref: BlockReference, holders: AuthoritySet) { + self.pending_fetches + .entry(block_ref) + .and_modify(|fetch| fetch.holders |= holders) + .or_insert(PendingHeaderFetch { + holders, + requested_from: AuthoritySet::default(), + }); + self.send_fetch_wave(block_ref); + } + + fn send_fetch_wave(&mut self, block_ref: BlockReference) { + let Some(fetch) = self.pending_fetches.get_mut(&block_ref) else { + return; + }; + let eligible: Vec<_> = fetch + .holders + .present() + .filter(|peer| *peer != self.own_authority && self.connected_peers.contains(*peer)) + .collect(); + let mut recipients: Vec<_> = eligible + .iter() + .copied() + .filter(|peer| !fetch.requested_from.contains(*peer)) + .take(HEADER_REQUEST_FANOUT) + .collect(); + if recipients.is_empty() && !eligible.is_empty() { + fetch.requested_from.clear(); + recipients.extend(eligible.into_iter().take(HEADER_REQUEST_FANOUT)); + } + for recipient in &recipients { + fetch.requested_from.insert(*recipient); + } + for recipient in recipients { + self.send_network(recipient, NetworkMessage::RbcHeaderRequest(block_ref)); + } + } + + fn retry_pending_headers(&mut self) { + let block_refs: Vec<_> = self.pending_fetches.keys().copied().collect(); + for block_ref in block_refs { + match self.kernel.retry_header_request(block_ref) { + Ok(Some(RbcEffect::NeedHeader { holders, .. })) => { + if let Some(fetch) = self.pending_fetches.get_mut(&block_ref) { + fetch.holders |= holders; + } + self.send_fetch_wave(block_ref); + } + Ok(Some(_)) => unreachable!("header retry can only request a header"), + Ok(None) => { + self.pending_fetches.remove(&block_ref); + } + Err(error) => { + self.pending_fetches.remove(&block_ref); + self.reject(None, error.into()); + } + } + } + } + + fn peer_connected(&mut self, peer: AuthorityIndex) { + if !self.committee.known_authority(peer) { + self.reject(Some(peer), RbcError::UnknownAuthority(peer).into()); + return; + } + if peer == self.own_authority { + self.reject(Some(peer), RbcError::LoopbackPhase.into()); + return; + } + self.connected_peers.insert(peer); + + let initials: Vec<_> = self + .retained_initials + .iter() + .filter_map(|((_, recipient), proposal)| { + (*recipient == peer).then_some(proposal.clone()) + }) + .collect(); + for proposal in initials { + self.send_network(peer, NetworkMessage::RbcInitial(proposal)); + } + + let phases: Vec<_> = self.retained_phases.iter().copied().collect(); + for (block_ref, phase) in phases { + match self.kernel.make_phase_message(phase, block_ref, peer) { + Ok(message) => self.send_network(peer, NetworkMessage::RbcPhase(message)), + Err(error) => self.reject(Some(peer), error.into()), + } + } + + let pending: Vec<_> = self + .pending_fetches + .iter() + .filter_map(|(block_ref, fetch)| fetch.holders.contains(peer).then_some(*block_ref)) + .collect(); + for block_ref in pending { + if let Some(fetch) = self.pending_fetches.get_mut(&block_ref) { + fetch.requested_from.insert(peer); + } + self.send_network(peer, NetworkMessage::RbcHeaderRequest(block_ref)); + } + } + + fn peer_disconnected(&mut self, peer: AuthorityIndex) { + if !self.committee.known_authority(peer) { + self.reject(Some(peer), RbcError::UnknownAuthority(peer).into()); + return; + } + if peer == self.own_authority { + self.reject(Some(peer), RbcError::LoopbackPhase.into()); + return; + } + self.connected_peers.remove(peer); + } + + fn send_network(&self, recipient: AuthorityIndex, message: NetworkMessage) { + let _ = self + .events + .send(RbcServiceEvent::Network { recipient, message }); + } + + fn reject(&self, peer: Option, error: RbcServiceError) { + let _ = self.events.send(RbcServiceEvent::Rejected { peer, error }); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::{ + crypto::{ + dummy_ml_dsa_44_signer, dummy_ml_dsa_65_signer, dummy_signer, mac_keyrings_for_test, + }, + starfish_rbc::RbcPhase, + types::{TransactionData, VerifiedBlock}, + }; + + fn instance() -> RbcProtocolInstanceId { + RbcProtocolInstanceId::new([0x51; 32]).unwrap() + } + + fn local_header(round: RoundNumber, committee_size: AuthorityIndex) -> RbcLocalHeader { + RbcLocalHeader { + round, + block_references: (0..committee_size) + .map(|authority| BlockReference::new_test(authority, round - 1)) + .collect(), + acknowledgment_references: Vec::new(), + meta_creation_time_ns: 17, + transactions_commitment: TransactionsCommitment::default(), + starfish_rbc_v3: None, + } + } + + fn start_service( + own_authority: AuthorityIndex, + scheme: BlockAuthenticationScheme, + ) -> ( + RbcServiceHandle, + mpsc::UnboundedReceiver, + JoinHandle<()>, + ) { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let authenticator = match scheme { + BlockAuthenticationScheme::Ed25519 => RbcInitialAuthenticator::Ed25519(dummy_signer()), + BlockAuthenticationScheme::MacVector => RbcInitialAuthenticator::Mac, + BlockAuthenticationScheme::MlDsa44 => { + RbcInitialAuthenticator::MlDsa44(dummy_ml_dsa_44_signer()) + } + BlockAuthenticationScheme::MlDsa65 => { + RbcInitialAuthenticator::MlDsa65(dummy_ml_dsa_65_signer()) + } + }; + start_starfish_rbc_service( + committee, + own_authority, + instance(), + scheme, + Arc::new(keyrings[own_authority as usize].clone()), + authenticator, + 1, + Duration::from_secs(3_600), + ) + .unwrap() + } + + fn start_single_dag_service() -> ( + RbcServiceHandle, + mpsc::UnboundedReceiver, + JoinHandle<()>, + ) { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + start_starfish_rbc_service_with_phase_authority( + committee, + 0, + instance(), + BlockAuthenticationScheme::MacVector, + Arc::new(keyrings[0].clone()), + RbcInitialAuthenticator::Mac, + 1, + Duration::from_secs(3_600), + RbcPhaseAuthorityV1::EmbeddedSingleDag { + echo_qc_fast_path: false, + }, + ) + .unwrap() + } + async fn next_event(events: &mut mpsc::UnboundedReceiver) -> RbcServiceEvent { + tokio::time::timeout(Duration::from_secs(2), events.recv()) + .await + .expect("service event timed out") + .expect("service stopped unexpectedly") + } + + #[tokio::test] + async fn local_initial_authenticator_wiring_covers_all_four_modes() { + for scheme in [ + BlockAuthenticationScheme::Ed25519, + BlockAuthenticationScheme::MlDsa44, + BlockAuthenticationScheme::MlDsa65, + BlockAuthenticationScheme::MacVector, + ] { + let (handle, mut events, task) = start_service(0, scheme); + let canonical = handle.start_local_header(local_header(1, 4)).await.unwrap(); + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::HeaderStaged(ref header) + if header.reference() == canonical.reference() + )); + let RbcServiceEvent::Network { + recipient: 1, + message: NetworkMessage::RbcInitial(proposal), + } = next_event(&mut events).await + else { + panic!("expected first recipient's INIT") + }; + let proof_matches = matches!( + (scheme, proposal.proof()), + ( + BlockAuthenticationScheme::Ed25519, + RbcInitialProof::Ed25519(_) + ) | ( + BlockAuthenticationScheme::MlDsa44, + RbcInitialProof::MlDsa44(_) + ) | ( + BlockAuthenticationScheme::MlDsa65, + RbcInitialProof::MlDsa65(_) + ) | ( + BlockAuthenticationScheme::MacVector, + RbcInitialProof::Mac(_) + ) + ); + assert!(proof_matches, "wrong INIT proof for {scheme:?}"); + + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let mut receiver = StarfishRbcKernel::new( + committee, + 1, + instance(), + scheme, + Arc::new(keyrings[1].clone()), + 1, + ) + .unwrap(); + assert!(matches!( + receiver + .accept_direct_initial_header(0, proposal.header().clone(), proposal.proof(),) + .unwrap(), + RbcInitialHeaderOutcome::Authenticated { .. } + )); + + // Two remaining INITs and three recipient-specific ECHOs. + for _ in 0..5 { + let _ = next_event(&mut events).await; + } + drop(handle); + task.await.unwrap(); + } + } + + #[tokio::test] + async fn single_dag_phase_authority_emits_typed_reference_not_phase_message() { + let (handle, mut events, task) = start_single_dag_service(); + let mut header = local_header(1, 4); + header.starfish_rbc_v3 = Some(StarfishRbcFieldsV3::default()); + let canonical = handle.start_local_header(header).await.unwrap(); + let mut staged = false; + let mut initials = 0; + let mut references = 0; + for _ in 0..5 { + match next_event(&mut events).await { + RbcServiceEvent::HeaderStaged(header) => { + assert_eq!(header.reference(), canonical.reference()); + staged = true; + } + RbcServiceEvent::Network { + message: NetworkMessage::RbcInitial(_), + .. + } => initials += 1, + RbcServiceEvent::ReferenceReady(reference) => { + assert_eq!(reference.kind(), StarfishRbcReferenceKindV3::Echo); + assert_eq!(reference.reference(), canonical.reference()); + references += 1; + } + RbcServiceEvent::Network { + message: NetworkMessage::RbcPhase(_), + .. + } => panic!("single-DAG mode emitted a standalone phase message"), + event => panic!("unexpected single-DAG startup event: {event:?}"), + } + } + assert!(staged); + assert_eq!(initials, 3); + assert_eq!(references, 1); + drop(handle); + task.await.unwrap(); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_local_start_waits_for_kernel_selection_and_event_enqueue() { + let (handle, mut events, task) = start_service(0, BlockAuthenticationScheme::Ed25519); + let blocking_handle = handle.clone(); + let canonical = tokio::task::spawn_blocking(move || { + blocking_handle.start_local_header_with_payload_blocking(local_header(1, 4), None) + }) + .await + .unwrap() + .unwrap(); + + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::HeaderStaged(ref header) + if header.reference() == canonical.reference() + )); + drop(handle); + task.await.unwrap(); + } + + #[tokio::test] + async fn local_payload_is_embedded_in_initial_and_replayed_before_phase() { + let (handle, mut events, task) = start_service(0, BlockAuthenticationScheme::MacVector); + let transaction_data = TransactionData::new(Vec::new()); + let canonical = handle + .start_local_header_with_payload(local_header(1, 4), Some(transaction_data)) + .await + .unwrap(); + + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::HeaderStaged(ref header) if header.reference() == canonical.reference() + )); + for expected_recipient in 1..4 { + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::Network { + recipient, + message: NetworkMessage::RbcInitial(ref proposal), + } if recipient == expected_recipient + && proposal.header() == &canonical + && proposal.transaction_data().is_some() + )); + } + + // Drain the three initial ECHO messages, then reconnect one peer. The + // replay FIFO must put payload-bearing INIT before the rematerialized + // ECHO. + for _ in 0..3 { + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::Network { + message: NetworkMessage::RbcPhase(_), + .. + } + )); + } + handle.peer_connected(2).unwrap(); + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::Network { + recipient: 2, + message: NetworkMessage::RbcInitial(ref proposal), + } if proposal.transaction_data().is_some() + )); + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::Network { + recipient: 2, + message: NetworkMessage::RbcPhase(ref message), + } if message.phase() == RbcPhase::Echo + )); + + drop(handle); + task.await.unwrap(); + } + + #[tokio::test] + async fn direct_initial_stages_header_and_payload_before_echo() { + let (author, mut author_events, author_task) = + start_service(0, BlockAuthenticationScheme::MacVector); + author + .start_local_header_with_payload( + local_header(1, 4), + Some(TransactionData::new(Vec::new())), + ) + .await + .unwrap(); + assert!(matches!( + next_event(&mut author_events).await, + RbcServiceEvent::HeaderStaged(_) + )); + let proposal = loop { + if let RbcServiceEvent::Network { + recipient: 1, + message: NetworkMessage::RbcInitial(proposal), + } = next_event(&mut author_events).await + { + break proposal; + } + }; + + let (receiver, mut receiver_events, receiver_task) = + start_service(1, BlockAuthenticationScheme::MacVector); + receiver.direct_initial(0, proposal).unwrap(); + let staged = match next_event(&mut receiver_events).await { + RbcServiceEvent::HeaderStaged(header) => header, + other => panic!("expected staged header, got {other:?}"), + }; + assert!(matches!( + next_event(&mut receiver_events).await, + RbcServiceEvent::TransactionPayloadStaged { + peer: 0, + ref header, + ref transaction_data, + } if header.reference() == staged.reference() + && transaction_data.number_transactions() == 0 + )); + assert!(matches!( + next_event(&mut receiver_events).await, + RbcServiceEvent::Network { + message: NetworkMessage::RbcPhase(ref message), + .. + } if message.phase() == RbcPhase::Echo + )); + + drop(author); + drop(receiver); + author_task.await.unwrap(); + receiver_task.await.unwrap(); + } + + #[tokio::test] + async fn local_start_advances_the_admission_window_before_pinning() { + let (handle, events, task) = start_service(0, BlockAuthenticationScheme::Ed25519); + + // The kernel starts at round 1, whose ordinary future-admission window + // ends at round 101. A locally selected proposal is trusted progress + // and must move that window before its own reference is validated. + let canonical = handle + .start_local_header(local_header(150, 4)) + .await + .unwrap(); + assert_eq!(canonical.reference().round, 150); + + drop(handle); + drop(events); + task.await.unwrap(); + } + + #[tokio::test] + async fn local_mac_start_materializes_distinct_recipient_messages() { + let (handle, mut events, task) = start_service(0, BlockAuthenticationScheme::MacVector); + let canonical = handle.start_local_header(local_header(1, 4)).await.unwrap(); + + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::HeaderStaged(ref header) if header.reference() == canonical.reference() + )); + + let mut initial_proofs = Vec::new(); + for expected_recipient in 1..4 { + let RbcServiceEvent::Network { recipient, message } = next_event(&mut events).await + else { + panic!("expected initial network event") + }; + assert_eq!(recipient, expected_recipient); + let NetworkMessage::RbcInitial(proposal) = message else { + panic!("expected initial proposal") + }; + assert_eq!(proposal.header(), &canonical); + let RbcInitialProof::Mac(tag) = proposal.proof() else { + panic!("MAC mode must send one tag") + }; + initial_proofs.push(*tag); + } + assert!(initial_proofs.windows(2).all(|pair| pair[0] != pair[1])); + + let mut phases = Vec::new(); + for expected_recipient in 1..4 { + let RbcServiceEvent::Network { recipient, message } = next_event(&mut events).await + else { + panic!("expected phase network event") + }; + assert_eq!(recipient, expected_recipient); + let NetworkMessage::RbcPhase(message) = message else { + panic!("expected RBC phase") + }; + assert_eq!(message.phase(), RbcPhase::Echo); + assert_eq!(message.recipient(), recipient); + assert_eq!(message.sender(), 0); + phases.push(message); + } + assert!(phases.windows(2).all(|pair| pair[0] != pair[1])); + + // The initial proposal and authorized phase intent survive a missing + // connection. Reconnection replays INIT first and rematerializes a + // fresh recipient-specific phase message from the kernel intent. + handle.peer_connected(2).unwrap(); + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::Network { + recipient: 2, + message: NetworkMessage::RbcInitial(ref proposal), + } if proposal.header() == &canonical + )); + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::Network { + recipient: 2, + message: NetworkMessage::RbcPhase(ref message), + } if message.phase() == RbcPhase::Echo && message.recipient() == 2 + )); + + handle.header_request(1, canonical.reference()).unwrap(); + let RbcServiceEvent::Network { recipient, message } = next_event(&mut events).await else { + panic!("expected header response") + }; + assert_eq!(recipient, 1); + assert!(matches!( + message, + NetworkMessage::RbcHeaderResponse(header) if header == canonical + )); + + drop(handle); + task.await.unwrap(); + } + + fn echo_messages_for( + canonical: &RbcCanonicalHeader, + recipient: AuthorityIndex, + ) -> Vec<(AuthorityIndex, RbcPhaseMessage)> { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let mut author = StarfishRbcKernel::new( + committee.clone(), + 0, + instance(), + BlockAuthenticationScheme::Ed25519, + Arc::new(keyrings[0].clone()), + 1, + ) + .unwrap(); + let local = author + .start_local_initial_header( + canonical.reference().round, + canonical.block_references().to_vec(), + canonical.acknowledgment_references(), + canonical.meta_creation_time_ns(), + canonical.transactions_commitment(), + ) + .unwrap(); + let digest = author.make_local_initial_signature_digest(&local).unwrap(); + let proof = RbcInitialProof::Ed25519(dummy_signer().sign_digest(&digest)); + drop(local.into_parts()); + + let mut messages = vec![( + 0, + author + .make_phase_message(RbcPhase::Echo, canonical.reference(), recipient) + .unwrap(), + )]; + for sender in 1..=2 { + let mut kernel = StarfishRbcKernel::new( + committee.clone(), + sender, + instance(), + BlockAuthenticationScheme::Ed25519, + Arc::new(keyrings[sender as usize].clone()), + 1, + ) + .unwrap(); + let outcome = kernel + .accept_direct_initial_header(0, canonical.clone(), &proof) + .unwrap(); + assert!(matches!( + outcome, + RbcInitialHeaderOutcome::Authenticated { .. } + )); + messages.push(( + sender, + kernel + .make_phase_message(RbcPhase::Echo, canonical.reference(), recipient) + .unwrap(), + )); + } + messages + } + + #[tokio::test] + async fn header_recovery_is_durable_and_fans_out_to_new_holders() { + let (handle, mut events, task) = start_service(3, BlockAuthenticationScheme::Ed25519); + for peer in 0..3 { + handle.peer_connected(peer).unwrap(); + } + + let canonical = RbcCanonicalHeader::try_new( + 0, + 1, + (0..4) + .map(|authority| *VerifiedBlock::new_genesis(authority).reference()) + .collect(), + Vec::new(), + 23, + TransactionsCommitment::default(), + ) + .unwrap(); + for (peer, message) in echo_messages_for(&canonical, 3) { + handle.phase(peer, message).unwrap(); + } + + let mut first_wave = Vec::new(); + for _ in 0..2 { + let RbcServiceEvent::Network { recipient, message } = next_event(&mut events).await + else { + panic!("expected header request") + }; + assert!(matches!( + message, + NetworkMessage::RbcHeaderRequest(block_ref) if block_ref == canonical.reference() + )); + first_wave.push(recipient); + } + assert_eq!(first_wave, vec![0, 1]); + + let unrelated = RbcCanonicalHeader::try_new( + 1, + 1, + canonical.block_references().to_vec(), + Vec::new(), + 24, + TransactionsCommitment::default(), + ) + .unwrap(); + handle.header_response(0, unrelated).unwrap(); + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::Rejected { + peer: Some(0), + error: RbcServiceError::UnexpectedHeaderResponse(_), + } + )); + + // Replacing a connection does not own or discard the fetch. The + // central actor immediately retries the still-pending reference when + // the untried authenticated holder reconnects. + handle.peer_disconnected(2).unwrap(); + handle.peer_connected(2).unwrap(); + let RbcServiceEvent::Network { recipient, message } = next_event(&mut events).await else { + panic!("expected reconnect retry to the untried holder") + }; + assert_eq!(recipient, 2); + assert!(matches!( + message, + NetworkMessage::RbcHeaderRequest(block_ref) if block_ref == canonical.reference() + )); + + handle.header_response(2, canonical.clone()).unwrap(); + assert!(matches!( + next_event(&mut events).await, + RbcServiceEvent::HeaderStaged(ref header) if header.reference() == canonical.reference() + )); + for expected_recipient in 0..3 { + let RbcServiceEvent::Network { recipient, message } = next_event(&mut events).await + else { + panic!("expected READY after recovery") + }; + assert_eq!(recipient, expected_recipient); + assert!(matches!( + message, + NetworkMessage::RbcPhase(ref phase) + if phase.phase() == RbcPhase::Ready + && phase.recipient() == expected_recipient + )); + } + + // Another holder may have answered the same request concurrently. + // Once the exact header is pinned, that duplicate is idempotent. + handle.header_response(1, canonical).unwrap(); + handle.retry_headers().await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(20), events.recv()) + .await + .is_err() + ); + + drop(handle); + task.await.unwrap(); + } + + #[test] + fn service_rejects_mismatched_initial_authenticator() { + let committee = Committee::new_test(vec![1; 4]); + let keyrings = mac_keyrings_for_test(4); + let result = start_starfish_rbc_service( + committee, + 0, + instance(), + BlockAuthenticationScheme::Ed25519, + Arc::new(keyrings[0].clone()), + RbcInitialAuthenticator::Mac, + 1, + Duration::from_secs(1), + ); + assert!(matches!( + result, + Err(RbcServiceError::InitialAuthenticatorSchemeMismatch { .. }) + )); + } +} diff --git a/crates/starfish-core/src/syncer.rs b/crates/starfish-core/src/syncer.rs index 9ef887cb..c5b4b422 100644 --- a/crates/starfish-core/src/syncer.rs +++ b/crates/starfish-core/src/syncer.rs @@ -2,7 +2,11 @@ // Modifications Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::{collections::BTreeSet, sync::Arc, time::Instant}; +use std::{ + collections::BTreeSet, + sync::Arc, + time::{Duration, Instant}, +}; use ahash::AHashSet; @@ -19,6 +23,8 @@ use crate::{ metrics::Metrics, runtime::timestamp_utc, sailfish_service::SailfishServiceMessage, + starfish_rbc::{PinnedRbcHeader, RbcCanonicalHeader}, + starfish_rbc_service::{RbcLocalHeader, RbcServiceHandle}, types::{ AuthorityIndex, BlockReference, PartialSig, PartialSigKind, ProvableShard, ReconstructedTransactionData, RoundNumber, SailfishNoVoteCert, SailfishTimeoutCert, Stake, @@ -37,6 +43,13 @@ pub enum BlockCreationReason { PostCommit, } +/// Testbed pacing for the V3 single-DAG protocol. Authenticated quorum may +/// advance the production clock immediately, but an honest authority fixes at +/// most one ordinary block per interval. This prevents a zero-latency quorum +/// from becoming a self-sustaining empty-block loop while leaving the existing +/// leader timeout and all consensus thresholds unchanged. +pub(crate) const STARFISH_RBC_SINGLE_DAG_ROUND_INTERVAL: Duration = Duration::from_millis(50); + impl BlockCreationReason { pub fn as_str(self) -> &'static str { match self { @@ -56,6 +69,7 @@ pub struct Syncer { forced_block_rounds: BTreeSet, proposal_wait_started_at: Option, proposal_wait_round: Option, + single_dag_last_proposal_at: Option, signals: S, commit_observer: C, pub(crate) connected_authorities: AHashSet, @@ -64,6 +78,7 @@ pub struct Syncer { pub(crate) metrics: Arc, bls_tx: Option>, sailfish_tx: Option>, + starfish_rbc_service: Option, } pub trait SyncerSignals: Send + Sync { @@ -95,6 +110,7 @@ impl Syncer { metrics: Arc, bls_tx: Option>, sailfish_tx: Option>, + starfish_rbc_service: Option, ) -> Self { let committee_size = core.committee().len(); let own_stake = core @@ -106,6 +122,7 @@ impl Syncer { forced_block_rounds: BTreeSet::new(), proposal_wait_started_at: None, proposal_wait_round: None, + single_dag_last_proposal_at: None, signals, commit_observer, connected_authorities: AHashSet::with_capacity(committee_size), @@ -114,6 +131,7 @@ impl Syncer { metrics, bls_tx, sailfish_tx, + starfish_rbc_service, } } @@ -147,6 +165,11 @@ impl Syncer { if success { tracing::debug!("Attempt to create block from syncer after adding block"); self.try_new_block(BlockCreationReason::NewBlocks); + if self.core.dag_state().consensus_protocol.is_starfish_rbc() { + // A previously delivered header may have become dirty-DAG + // connected and clean during insertion. + self.try_new_commit(); + } } ( pending_blocks_with_transactions, @@ -175,6 +198,9 @@ impl Syncer { if success { tracing::debug!("Attempt to create block from syncer after adding headers"); self.try_new_block(BlockCreationReason::NewHeaders); + if self.core.dag_state().consensus_protocol.is_starfish_rbc() { + self.try_new_commit(); + } } (missing_parents, processed_refs) } @@ -204,6 +230,34 @@ impl Syncer { } } + /// Called after the local Starfish-RBC service delivers exact header + /// references. Delivery is separate from dirty insertion and transaction + /// availability; any newly dependency-closed vertices can immediately + /// unblock both proposal and commit paths. + pub fn apply_starfish_rbc_deliveries(&mut self, delivered_headers: Vec) { + let previous_rounds = self.capture_rounds(); + if self + .core + .dag_state() + .apply_starfish_rbc_deliveries(&delivered_headers) + { + self.maybe_update_proposal_wait(); + self.maybe_signal_proposal_round_advance(previous_rounds); + self.try_new_block(BlockCreationReason::CertificateEvent); + self.try_new_commit(); + } + } + + /// Queue one locally locked RBC statement for the next ordinary + /// single-DAG block. The statement changes no delivery state until peers + /// authenticate the carrying block. + pub fn apply_starfish_rbc_reference( + &mut self, + reference: crate::types::StarfishRbcReferenceV3, + ) { + self.core.add_starfish_rbc_reference(reference); + self.try_new_block(BlockCreationReason::CertificateEvent); + } /// Store a Sailfish++ timeout certificate in DagState and retry block /// creation (a TC may unblock block creation for the next round). pub fn apply_timeout_cert(&mut self, cert: SailfishTimeoutCert) { @@ -299,6 +353,18 @@ impl Syncer { } else { reason }; + if self + .core + .dag_state() + .consensus_protocol + .is_starfish_rbc_single_dag() + && !matches!(effective_reason, BlockCreationReason::ForceTimeout) + && self.single_dag_last_proposal_at.is_some_and(|created_at| { + created_at.elapsed() < STARFISH_RBC_SINGLE_DAG_ROUND_INTERVAL + }) + { + return false; + } self.create_new_block(effective_reason) } @@ -306,6 +372,32 @@ impl Syncer { tracing::debug!("Attempt to create new block in syncer after one trigger"); let previous_rounds = self.capture_rounds(); if let Some(ref block) = self.core.try_new_block(reason.as_str()) { + if self + .core + .dag_state() + .consensus_protocol + .is_starfish_rbc_single_dag() + { + self.single_dag_last_proposal_at = Some(Instant::now()); + } + if self.core.dag_state().consensus_protocol.is_starfish_rbc() { + let canonical = RbcCanonicalHeader::from_block_header(block.header()) + .expect("locally built Starfish-RBC block must have canonical header content"); + let selected = self + .starfish_rbc_service + .as_ref() + .expect("Starfish-RBC protocol must start its RBC service") + .start_local_header_with_payload_blocking( + RbcLocalHeader::from_canonical(&canonical), + block.transaction_data().cloned(), + ) + .expect("local Starfish-RBC header must be accepted before dissemination"); + assert_eq!( + selected.reference(), + *block.reference(), + "RBC service selected a different local header reference" + ); + } if let Some(started_at) = self.proposal_wait_started_at.take() { self.metrics .proposal_wait_time_total_us @@ -584,7 +676,7 @@ mod tests { assert_eq!(core.dag_state().proposal_round(), 3); assert_eq!(core.last_proposed(), 0); - let mut syncer = Syncer::new(core, false, NoopCommitObserver, metrics, None, None); + let mut syncer = Syncer::new(core, false, NoopCommitObserver, metrics, None, None, None); syncer.connected_authorities.extend([1, 2, 3]); syncer.subscribed_by_authorities.extend([1, 2, 3]); syncer.recompute_subscriber_stake(); @@ -681,6 +773,7 @@ mod tests { metrics, None, None, + None, ); syncer.connected_authorities.extend([1, 2, 3]); syncer.subscribed_by_authorities.extend([1, 2, 3]); diff --git a/crates/starfish-core/src/threshold_clock.rs b/crates/starfish-core/src/threshold_clock.rs index b70bf4c0..8a9d8c79 100644 --- a/crates/starfish-core/src/threshold_clock.rs +++ b/crates/starfish-core/src/threshold_clock.rs @@ -75,10 +75,7 @@ impl ThresholdClockAggregator { mod tests { use super::*; - use crate::{ - crypto::SignatureBytes, - types::{AckFields, AuthorityIndex, BlockDigest, RoundNumber}, - }; + use crate::types::{AckFields, AuthorityIndex, BlockAuthentication, BlockDigest, RoundNumber}; fn make_header( authority: AuthorityIndex, @@ -100,14 +97,13 @@ mod tests { &block_references, &ack_refs, 0, - &SignatureBytes::default(), None, None, ), }, block_references, meta_creation_time_ns: 0, - signature: SignatureBytes::default(), + authentication: BlockAuthentication::None, transactions_commitment: None, ack: Some(AckFields { intersection: None, @@ -117,6 +113,7 @@ mod tests { bls: None, sailfish: None, unprovable_certificate: None, + starfish_rbc_v3: None, serialized: None, } } diff --git a/crates/starfish-core/src/types.rs b/crates/starfish-core/src/types.rs index 53cbc6e6..568612d1 100644 --- a/crates/starfish-core/src/types.rs +++ b/crates/starfish-core/src/types.rs @@ -35,18 +35,20 @@ use ahash::AHashSet; use bytes::Bytes; use eyre::{bail, ensure}; use reed_solomon_simd::{ReedSolomonDecoder, ReedSolomonEncoder}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use crate::{ committee::Committee, crypto, crypto::{ - AsBytes, BlsSignatureBytes, BlsSigner, CryptoHash, SignatureBytes, Signer, + AsBytes, BlsSignatureBytes, BlsSigner, CryptoHash, MacKey, MacTag, MlDsa44SignatureBytes, + MlDsa44Signer, MlDsa65SignatureBytes, MlDsa65Signer, SignatureBytes, Signer, TransactionsCommitment, }, dag_state::ConsensusProtocol, data::{Data, IN_MEMORY_BLOCKS, IN_MEMORY_BLOCKS_BYTES}, encoder::ShardEncoder, + starfish_rbc::RbcCanonicalHeader, threshold_clock::threshold_clock_valid_block_header, }; @@ -87,9 +89,7 @@ impl PartialOrd for BlockReference { } // --------------------------------------------------------------------------- -// BlockHeader — signed, content-addressed block identity. -// Contains exactly the fields that feed into BlockDigest::new() and -// sign_block(). +// BlockHeader — authenticated, content-addressed block identity. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- @@ -112,6 +112,89 @@ pub struct AckFields { pub(crate) extra_references: Vec, } +/// The reliable-broadcast statement carried by an ordinary Starfish block in +/// the single-DAG protocol. The carrying block's author authentication also +/// authenticates these references; they deliberately have no second block or +/// carrier identity. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub enum StarfishRbcReferenceKindV3 { + Echo, + Ready, +} + +impl StarfishRbcReferenceKindV3 { + pub(crate) fn tag(self) -> u8 { + match self { + Self::Echo => 0x01, + Self::Ready => 0x02, + } + } +} + +/// One typed RBC reference embedded in a normal Starfish block. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct StarfishRbcReferenceV3 { + kind: StarfishRbcReferenceKindV3, + reference: BlockReference, +} + +impl StarfishRbcReferenceV3 { + pub fn new(kind: StarfishRbcReferenceKindV3, reference: BlockReference) -> Self { + Self { kind, reference } + } + + pub fn kind(self) -> StarfishRbcReferenceKindV3 { + self.kind + } + + pub fn reference(self) -> BlockReference { + self.reference + } +} + +/// Versioned single-DAG Starfish-RBC extension. +/// +/// `Some(empty)` is meaningful: it identifies a V3 block even when no RBC +/// statement is ready in that block. References are canonicalized so arrival +/// order cannot create multiple block identities for the same evidence set. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct StarfishRbcFieldsV3 { + references: Vec, +} + +impl StarfishRbcFieldsV3 { + pub fn new(mut references: Vec) -> Self { + references.sort_unstable(); + references.dedup(); + Self { references } + } + + pub fn references(&self) -> &[StarfishRbcReferenceV3] { + &self.references + } + + pub(crate) fn validate_for_block( + &self, + committee: &Committee, + block_round: RoundNumber, + ) -> bool { + if self.references.len() > committee.len().saturating_mul(6) { + return false; + } + if self.references.windows(2).any(|pair| pair[0] >= pair[1]) { + return false; + } + let mut statements = AHashSet::new(); + self.references.iter().all(|evidence| { + let reference = evidence.reference(); + reference.round > 0 + && reference.round <= block_round + && committee.known_authority(reference.authority) + && statements.insert((evidence.kind(), reference.authority, reference.round)) + }) + } +} + /// BLS certificate data (StarfishBls only). /// /// `certified_leader` pairs the leader ref with an aggregate certificate once @@ -239,7 +322,7 @@ pub struct SailfishNoVoteCert { } /// Protocol-specific fields embedded in SailfishPlusPlus block headers. -/// Part of the signed block hash. +/// Part of the authenticated block content hash. #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct SailfishFields { /// Timeout certificate for the previous round, if this block advances @@ -252,11 +335,127 @@ pub struct SailfishFields { } // --------------------------------------------------------------------------- -// BlockHeader — signed, content-addressed block identity. -// Contains exactly the fields that feed into BlockDigest::new() and -// sign_block(). +// BlockHeader — authenticated, content-addressed block identity. // --------------------------------------------------------------------------- +#[derive(Clone, Eq, PartialEq, Serialize, Deserialize, Debug)] +pub enum BlockAuthentication { + /// Only valid for locally constructed genesis blocks. + None, + Ed25519(SignatureBytes), + /// Complete author-generated authenticator retained by direct recipients. + MacVector(#[serde(with = "flat_mac_vector")] Vec), + /// Recipient-specific authenticator selected from a full vector by a relay. + MacTag(MacTag), + MlDsa44(MlDsa44SignatureBytes), + MlDsa65(MlDsa65SignatureBytes), +} + +mod flat_mac_vector { + use super::*; + + pub fn serialize(tags: &[MacTag], serializer: S) -> Result { + if serializer.is_human_readable() { + return tags.serialize(serializer); + } + + let mut bytes = Vec::with_capacity(tags.len() * crypto::MAC_TAG_SIZE); + for tag in tags { + bytes.extend_from_slice(tag.as_ref()); + } + serializer.serialize_bytes(&bytes) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + if deserializer.is_human_readable() { + return Vec::::deserialize(deserializer); + } + + deserializer.deserialize_bytes(FlatMacVectorVisitor) + } + + struct FlatMacVectorVisitor; + + impl<'de> de::Visitor<'de> for FlatMacVectorVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "a flat byte string containing 32 bytes per MAC tag" + ) + } + + fn visit_bytes(self, bytes: &[u8]) -> Result { + let chunks = bytes.chunks_exact(crypto::MAC_TAG_SIZE); + if !chunks.remainder().is_empty() { + return Err(E::custom(format!( + "invalid flat MAC vector length {}; expected a multiple of {}", + bytes.len(), + crypto::MAC_TAG_SIZE + ))); + } + + Ok(chunks + .map(|chunk| { + let mut tag = [0; crypto::MAC_TAG_SIZE]; + tag.copy_from_slice(chunk); + MacTag::from_bytes(tag) + }) + .collect()) + } + + fn visit_byte_buf(self, bytes: Vec) -> Result { + self.visit_bytes(&bytes) + } + } +} + +#[derive(Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Debug)] +pub enum BlockAuthenticationScheme { + Ed25519, + MacVector, + MlDsa44, + MlDsa65, +} + +pub enum BlockAuthorizer<'a> { + Ed25519(&'a Signer), + MacVector(&'a [MacKey]), + MlDsa44(&'a MlDsa44Signer), + MlDsa65(&'a MlDsa65Signer), +} + +impl BlockAuthorizer<'_> { + fn authenticate( + &self, + author: AuthorityIndex, + content_digest: &BlockDigest, + ) -> BlockAuthentication { + match self { + Self::Ed25519(signer) => { + BlockAuthentication::Ed25519(signer.sign_digest(content_digest.as_array())) + } + Self::MacVector(keys) => BlockAuthentication::MacVector( + keys.iter() + .enumerate() + .map(|(recipient, key)| { + key.compute_tag(author, recipient as AuthorityIndex, content_digest) + }) + .collect(), + ), + Self::MlDsa44(signer) => { + BlockAuthentication::MlDsa44(signer.sign_digest(content_digest)) + } + Self::MlDsa65(signer) => { + BlockAuthentication::MlDsa65(signer.sign_digest(content_digest)) + } + } + } +} + #[derive(Clone, Serialize, Deserialize)] pub struct BlockHeader { // -- Base fields (all protocols) ------------------------------------------ @@ -267,8 +466,9 @@ pub struct BlockHeader { pub(crate) block_references: Vec, /// Creation time as reported by creator (currently not enforced). pub(crate) meta_creation_time_ns: TimestampNs, - /// Signature by the block author over the header fields. - pub(crate) signature: SignatureBytes, + /// Authentication proof over `reference.digest`. This field is not part of + /// the content-addressed block identity. + pub(crate) authentication: BlockAuthentication, /// Explicit payload commitment stored in the header. /// Starfish-family protocols carry the Merkle root over encoded shards. /// Full-block protocols leave this as `None` and recompute the raw @@ -299,6 +499,10 @@ pub struct BlockHeader { /// Some(empty)`; `false` (standard) means 2f+1 voters reference the /// leader but the strong-vote quorum is mixed. pub(crate) unprovable_certificate: Option<(BlockReference, bool)>, + /// Single-DAG Starfish-RBC evidence. `None` is the frozen direct-RBC V1 + /// header; `Some` selects the V3 identity and codec domain. + #[serde(default)] + pub(crate) starfish_rbc_v3: Option, // -- Cache (not serialized) ----------------------------------------------- /// Cached bincode-serialized bytes. Populated by `preserialize()` off the @@ -351,6 +555,10 @@ impl BlockHeader { ) } + pub fn starfish_rbc_v3(&self) -> Option<&StarfishRbcFieldsV3> { + self.starfish_rbc_v3.as_ref() + } + pub fn authority(&self) -> AuthorityIndex { self.reference.authority } @@ -367,8 +575,8 @@ impl BlockHeader { self.reference.author_round() } - pub fn signature(&self) -> &SignatureBytes { - &self.signature + pub fn authentication(&self) -> &BlockAuthentication { + &self.authentication } pub fn meta_creation_time_ns(&self) -> TimestampNs { @@ -481,7 +689,7 @@ impl BlockHeader { } } -fn expand_acknowledgments( +pub(crate) fn expand_acknowledgments( block_references: &[BlockReference], acknowledgment_intersection: Option, acknowledgment_references: &[BlockReference], @@ -509,7 +717,7 @@ fn count_acknowledgments( (block_references.len() - start) + acknowledgment_references.len() } -fn compress_acknowledgments( +pub(crate) fn compress_acknowledgments( block_references: &[BlockReference], acknowledgment_references: &[BlockReference], ) -> (Option, Vec) { @@ -583,6 +791,15 @@ pub struct TransactionData { pub(crate) serialized: Option, } +impl fmt::Debug for TransactionData { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TransactionData") + .field("transaction_count", &self.transactions.len()) + .finish() + } +} + impl TransactionData { pub fn new(transactions: Vec) -> Self { Self { @@ -717,6 +934,127 @@ pub struct VerifiedBlock { } impl VerifiedBlock { + /// Construct the authentication-free block carrier used by Starfish-RBC. + /// + /// The header identity is the canonical Starfish-RBC content digest. The + /// author proof is carried separately by the RBC INIT message and is never + /// serialized into this block header. Transaction data remains unchanged + /// from plain Starfish so the existing shard layer can be reused. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_starfish_rbc( + authority: AuthorityIndex, + round: RoundNumber, + block_references: Vec, + acknowledgment_references: Vec, + meta_creation_time_ns: TimestampNs, + transactions: Vec, + encoded_transactions: Option>, + ) -> Self { + Self::new_starfish_rbc_with_fields( + authority, + round, + block_references, + acknowledgment_references, + meta_creation_time_ns, + transactions, + encoded_transactions, + None, + ) + } + + /// Construct one ordinary Starfish block carrying V3 RBC reference + /// evidence. This is the single-DAG path: the resulting `BlockReference` + /// identifies both the consensus vertex and the RBC proposal. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_starfish_rbc_single_dag( + authority: AuthorityIndex, + round: RoundNumber, + block_references: Vec, + acknowledgment_references: Vec, + meta_creation_time_ns: TimestampNs, + transactions: Vec, + encoded_transactions: Option>, + rbc: StarfishRbcFieldsV3, + ) -> Self { + Self::new_starfish_rbc_with_fields( + authority, + round, + block_references, + acknowledgment_references, + meta_creation_time_ns, + transactions, + encoded_transactions, + Some(rbc), + ) + } + + #[allow(clippy::too_many_arguments)] + fn new_starfish_rbc_with_fields( + authority: AuthorityIndex, + round: RoundNumber, + block_references: Vec, + acknowledgment_references: Vec, + meta_creation_time_ns: TimestampNs, + transactions: Vec, + encoded_transactions: Option>, + starfish_rbc_v3: Option, + ) -> Self { + let transactions_commitment = if let Some(ref encoded) = encoded_transactions { + TransactionsCommitment::new_from_encoded_transactions(encoded, authority as usize).0 + } else { + TransactionsCommitment::default() + }; + let (intersection, extra_references) = + compress_acknowledgments(&block_references, &acknowledgment_references); + let logical_acknowledgments = + expand_acknowledgments(&block_references, intersection, &extra_references); + let digest = match starfish_rbc_v3.as_ref() { + Some(rbc) => BlockDigest::new_starfish_rbc_single_dag_header( + authority, + round, + &block_references, + &logical_acknowledgments, + meta_creation_time_ns, + transactions_commitment, + rbc, + ), + None => BlockDigest::new_starfish_rbc_header( + authority, + round, + &block_references, + &logical_acknowledgments, + meta_creation_time_ns, + transactions_commitment, + ), + }; + let transaction_data = + (!transactions.is_empty()).then(|| TransactionData::new(transactions)); + Self { + header: BlockHeader { + reference: BlockReference { + authority, + round, + digest, + }, + block_references, + meta_creation_time_ns, + authentication: BlockAuthentication::None, + transactions_commitment: Some(transactions_commitment), + ack: Some(AckFields { + intersection, + extra_references, + }), + strong_vote: None, + bls: None, + sailfish: None, + unprovable_certificate: None, + starfish_rbc_v3, + serialized: None, + }, + transaction_data, + } + } + pub fn new( authority: AuthorityIndex, round: RoundNumber, @@ -777,7 +1115,6 @@ impl VerifiedBlock { &block_references, &acknowledgments, meta_creation_time_ns, - &signature, merkle_root, strong_vote, unprovable_certificate.as_ref(), @@ -785,7 +1122,7 @@ impl VerifiedBlock { }, block_references, meta_creation_time_ns, - signature, + authentication: BlockAuthentication::Ed25519(signature), transactions_commitment: merkle_root, ack: Some(AckFields { intersection: acknowledgment_intersection, @@ -795,6 +1132,7 @@ impl VerifiedBlock { bls: bls.map(Box::new), sailfish: sailfish.map(Box::new), unprovable_certificate, + starfish_rbc_v3: None, serialized: None, }; @@ -822,20 +1160,20 @@ impl VerifiedBlock { &block_refs, &ack_refs, 0, - &SignatureBytes::default(), None, None, ), }, block_references: block_refs, meta_creation_time_ns: 0, - signature: SignatureBytes::default(), + authentication: BlockAuthentication::None, transactions_commitment: None, ack: None, strong_vote: None, bls: None, sailfish: None, unprovable_certificate: None, + starfish_rbc_v3: None, serialized: None, }; let mut block = Self { @@ -915,6 +1253,54 @@ impl VerifiedBlock { precomputed_leader_sig: Option, sailfish: Option, unprovable_certificate: Option<(BlockReference, bool)>, + ) -> Self { + let authorizer = BlockAuthorizer::Ed25519(signer); + Self::new_with_authorizer_and_unprovable( + authority, + round, + block_references, + voted_leader_ref, + acknowledgment_references, + meta_creation_time_ns, + &authorizer, + bls_signer, + committee_opt, + aggregate_dac_sigs, + transactions, + encoded_transactions, + consensus_protocol, + strong_vote, + aggregate_round_sig, + certified_leader, + precomputed_round_sig, + precomputed_leader_sig, + sailfish, + unprovable_certificate, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_with_authorizer_and_unprovable( + authority: AuthorityIndex, + round: RoundNumber, + block_references: Vec, + voted_leader_ref: Option, + acknowledgment_references: Vec, + meta_creation_time_ns: TimestampNs, + authorizer: &BlockAuthorizer<'_>, + bls_signer: Option<&BlsSigner>, + committee_opt: Option<&Committee>, + aggregate_dac_sigs: Vec, + transactions: Vec, + encoded_transactions: Option>, + consensus_protocol: ConsensusProtocol, + strong_vote: Option, + aggregate_round_sig: Option, + certified_leader: Option<(BlockReference, BlsAggregateCertificate)>, + precomputed_round_sig: Option, + precomputed_leader_sig: Option, + sailfish: Option, + unprovable_certificate: Option<(BlockReference, bool)>, ) -> Self { let supports_acknowledgments = consensus_protocol.supports_acknowledgments(); let header_transactions_commitment = if consensus_protocol.supports_acknowledgments() { @@ -950,7 +1336,7 @@ impl VerifiedBlock { &acknowledgment_references, aggregate_dac_sigs, ); - let signature = signer.sign_block_with_unprovable( + let content_digest = BlockDigest::new_without_transactions_with_unprovable( authority, round, &block_references, @@ -960,6 +1346,7 @@ impl VerifiedBlock { strong_vote, unprovable_certificate.as_ref(), ); + let authentication = authorizer.authenticate(authority, &content_digest); // Build BLS fields when the StarfishBls path is active. Partial round // and leader signatures are embedded as belt-and-suspenders alongside @@ -996,21 +1383,11 @@ impl VerifiedBlock { reference: BlockReference { authority, round, - digest: BlockDigest::new_without_transactions_with_unprovable( - authority, - round, - &block_references, - &acknowledgments, - meta_creation_time_ns, - &signature, - digest_transactions_commitment, - strong_vote, - unprovable_certificate.as_ref(), - ), + digest: content_digest, }, block_references, meta_creation_time_ns, - signature, + authentication, transactions_commitment: header_transactions_commitment, ack: supports_acknowledgments.then_some(AckFields { intersection: acknowledgment_intersection, @@ -1020,6 +1397,7 @@ impl VerifiedBlock { bls: bls.map(Box::new), sailfish: sailfish.map(Box::new), unprovable_certificate, + starfish_rbc_v3: None, serialized: None, }; @@ -1075,8 +1453,8 @@ impl VerifiedBlock { self.header.author_round() } - pub fn signature(&self) -> &SignatureBytes { - self.header.signature() + pub fn authentication(&self) -> &BlockAuthentication { + self.header.authentication() } pub fn meta_creation_time_ns(&self) -> TimestampNs { @@ -1145,6 +1523,48 @@ impl VerifiedBlock { self.transaction_data.is_some() } + /// Returns whether this copy retains the author's complete MAC vector and + /// can therefore be specialized for another recipient. + pub fn has_full_mac_vector(&self) -> bool { + matches!( + &self.header.authentication, + BlockAuthentication::MacVector(_) + ) + } + + /// Merge two verified copies of the same content-addressed block, keeping + /// the richest independently transported components from either copy: + /// transaction data and the author's complete MAC vector. + /// + /// Returns `None` when the references differ or the merge adds nothing. + pub fn merge_same_block(&self, incoming: &Self) -> Option { + if self.reference() != incoming.reference() { + return None; + } + + let mut merged = self.clone(); + let mut changed = false; + + if merged.transaction_data.is_none() && incoming.transaction_data.is_some() { + merged.transaction_data = incoming.transaction_data.clone(); + changed = true; + } + + if matches!( + &merged.header.authentication, + BlockAuthentication::MacTag(_) + ) && matches!( + &incoming.header.authentication, + BlockAuthentication::MacVector(_) + ) { + merged.header.authentication = incoming.header.authentication.clone(); + merged.header.serialized = None; + changed = true; + } + + changed.then_some(merged) + } + /// Create a lightweight copy with only the header (no transaction data). pub fn as_header_only(&self) -> Self { Self { @@ -1153,6 +1573,21 @@ impl VerifiedBlock { } } + /// Clone a block for relaying to `recipient`, replacing its complete MAC + /// vector with only that recipient's tag. A block that was itself received + /// with a single tag cannot be relayed again. + pub fn with_recipient_mac(&self, recipient: AuthorityIndex) -> Option { + let BlockAuthentication::MacVector(tags) = &self.header.authentication else { + return None; + }; + let tag = *tags.get(recipient as usize)?; + + let mut block = self.clone(); + block.header.authentication = BlockAuthentication::MacTag(tag); + block.header.serialized = None; + Some(block) + } + // --- Decomposition --- /// Extract the header, consuming self. @@ -1209,11 +1644,49 @@ impl VerifiedBlock { encoder: &mut Encoder, consensus_protocol: ConsensusProtocol, ) -> eyre::Result> { + self.verify_with_authentication( + committee, + own_id, + _peer_id, + encoder, + consensus_protocol, + BlockAuthenticationScheme::Ed25519, + &[], + ) + } + + pub fn verify_with_authentication( + &mut self, + committee: &Committee, + own_id: usize, + _peer_id: usize, + encoder: &mut Encoder, + consensus_protocol: ConsensusProtocol, + authentication_scheme: BlockAuthenticationScheme, + mac_keys: &[MacKey], + ) -> eyre::Result> { + if consensus_protocol.is_starfish_rbc() { + ensure!( + matches!(&self.header.authentication, BlockAuthentication::None), + "Starfish-RBC block carriers must not embed authentication" + ); + let (shard, _) = + self.verify_transactions(committee, own_id, encoder, consensus_protocol)?; + let canonical = RbcCanonicalHeader::from_block_header(&self.header) + .map_err(|error| eyre::eyre!(error))?; + canonical + .validate_for_committee(committee) + .map_err(|error| eyre::eyre!(error))?; + return Ok(shard); + } let (shard, digest_transactions_commitment) = self.verify_transactions(committee, own_id, encoder, consensus_protocol)?; self.verify_block_structure( committee, + own_id, consensus_protocol, + authentication_scheme, + mac_keys, digest_transactions_commitment, )?; Ok(shard) @@ -1280,11 +1753,14 @@ impl VerifiedBlock { } } - /// Verify digest, signature, includes, and threshold clock. + /// Verify content digest, authentication, includes, and threshold clock. fn verify_block_structure( &self, committee: &Committee, + own_id: usize, consensus_protocol: ConsensusProtocol, + authentication_scheme: BlockAuthenticationScheme, + mac_keys: &[MacKey], digest_transactions_commitment: Option, ) -> eyre::Result<()> { let round = self.round(); @@ -1313,7 +1789,6 @@ impl VerifiedBlock { &self.header.block_references, &acknowledgments, self.header.meta_creation_time_ns, - &self.header.signature, digest_transactions_commitment, self.header.strong_vote, self.header.unprovable_certificate.as_ref(), @@ -1324,17 +1799,72 @@ impl VerifiedBlock { digest, self.digest() ); - let pub_key = committee.get_public_key(self.authority()); - let Some(pub_key) = pub_key else { - bail!("Unknown block author {}", self.authority()) - }; if round == GENESIS_ROUND { bail!("Genesis block should not go through verification"); } - if let Err(e) = - pub_key.verify_signature_in_block(&self.header, digest_transactions_commitment) - { - bail!("Block signature verification has failed: {:?}", e); + match (authentication_scheme, &self.header.authentication) { + (BlockAuthenticationScheme::Ed25519, BlockAuthentication::Ed25519(signature)) => { + let Some(public_key) = committee.get_public_key(self.authority()) else { + bail!("Unknown block author {}", self.authority()) + }; + if let Err(error) = public_key.verify_digest_signature(digest.as_array(), signature) + { + bail!("Block Ed25519 verification has failed: {error:?}"); + } + } + (BlockAuthenticationScheme::MacVector, authentication) => { + let tag = match authentication { + BlockAuthentication::MacVector(tags) => { + ensure!( + tags.len() == committee.len(), + "MAC vector length {} does not match committee size {}", + tags.len(), + committee.len(), + ); + tags.get(own_id) + .ok_or_else(|| eyre::eyre!("Own authority index is out of bounds"))? + } + BlockAuthentication::MacTag(tag) => tag, + actual => { + bail!("Expected MacVector block authentication, received {actual:?}") + } + }; + ensure!( + own_id < committee.len(), + "Own authority index is out of bounds" + ); + ensure!( + mac_keys.len() == committee.len(), + "MAC keyring length {} does not match committee size {}", + mac_keys.len(), + committee.len(), + ); + let author = self.authority() as usize; + let Some(key) = mac_keys.get(author) else { + bail!("Unknown block author {}", self.authority()) + }; + let expected = key.compute_tag(self.authority(), own_id as AuthorityIndex, &digest); + ensure!(*tag == expected, "Block MAC verification has failed"); + } + (BlockAuthenticationScheme::MlDsa44, BlockAuthentication::MlDsa44(signature)) => { + let Some(public_key) = committee.get_ml_dsa_44_public_key(self.authority()) else { + bail!("Unknown block author {}", self.authority()) + }; + if let Err(error) = public_key.verify_digest_signature(&digest, signature) { + bail!("Block ML-DSA-44 verification has failed: {error:?}"); + } + } + (BlockAuthenticationScheme::MlDsa65, BlockAuthentication::MlDsa65(signature)) => { + let Some(public_key) = committee.get_ml_dsa_65_public_key(self.authority()) else { + bail!("Unknown block author {}", self.authority()) + }; + if let Err(error) = public_key.verify_digest_signature(&digest, signature) { + bail!("Block ML-DSA-65 verification has failed: {error:?}"); + } + } + (expected, actual) => { + bail!("Expected {expected:?} block authentication, received {actual:?}") + } } for include in &self.header.block_references { ensure!( @@ -1349,6 +1879,17 @@ impl VerifiedBlock { round ); } + ensure!( + consensus_protocol.is_starfish_rbc_single_dag() + == self.header.starfish_rbc_v3().is_some(), + "Only single-DAG Starfish-RBC blocks may carry V3 RBC reference evidence" + ); + if let Some(rbc) = self.header.starfish_rbc_v3() { + ensure!( + rbc.validate_for_block(committee, round), + "Single-DAG Starfish-RBC evidence is not canonical" + ); + } match consensus_protocol { ConsensusProtocol::StarfishBls => { ensure!( @@ -1539,7 +2080,10 @@ impl VerifiedBlock { ); } } - ConsensusProtocol::Starfish | ConsensusProtocol::StarfishSpeed => { + ConsensusProtocol::Starfish + | ConsensusProtocol::StarfishRbc + | ConsensusProtocol::StarfishRbcSingleDag + | ConsensusProtocol::StarfishSpeed => { ensure!( threshold_clock_valid_block_header(&self.header, committee), "Threshold clock is not valid" @@ -2147,6 +2691,430 @@ impl std::hash::Hash for VerifiedBlock { mod tests { use super::*; + fn make_authenticated_starfish_block( + committee: &Committee, + authorizer: &BlockAuthorizer<'_>, + ) -> VerifiedBlock { + make_authenticated_starfish_block_for_author(committee, 0, authorizer) + } + + fn make_authenticated_starfish_block_for_author( + committee: &Committee, + authority: AuthorityIndex, + authorizer: &BlockAuthorizer<'_>, + ) -> VerifiedBlock { + let round = 1; + let transactions = Vec::new(); + let mut encoder = Encoder::new(2, 4, 2).unwrap(); + let encoded_transactions = encoder.encode_transactions( + &transactions, + committee.info_length(), + committee.len() - committee.info_length(), + ); + VerifiedBlock::new_with_authorizer_and_unprovable( + authority, + round, + committee + .authorities() + .map(|authority| BlockReference::new_test(authority, 0)) + .collect(), + None, + Vec::new(), + 0, + authorizer, + None, + None, + Vec::new(), + transactions, + Some(encoded_transactions), + ConsensusProtocol::Starfish, + None, + None, + None, + None, + None, + None, + None, + ) + } + + #[test] + fn block_reference_depends_only_on_content_across_authentication_schemes() { + let committee = Committee::new_for_benchmarks(4); + let ed_signers = Signer::new_for_test(committee.len()); + let ml_dsa_44_signers = crypto::MlDsa44Signer::new_for_test(committee.len()); + let ml_dsa_65_signers = crypto::MlDsa65Signer::new_for_test(committee.len()); + let mac_keyrings = crypto::mac_keyrings_for_test(committee.len()); + let ed = BlockAuthorizer::Ed25519(&ed_signers[0]); + let mac = BlockAuthorizer::MacVector(&mac_keyrings[0]); + let ml_dsa_44 = BlockAuthorizer::MlDsa44(&ml_dsa_44_signers[0]); + let ml_dsa_65 = BlockAuthorizer::MlDsa65(&ml_dsa_65_signers[0]); + + let ed_block = make_authenticated_starfish_block(&committee, &ed); + let mac_block = make_authenticated_starfish_block(&committee, &mac); + let ml_dsa_44_block = make_authenticated_starfish_block(&committee, &ml_dsa_44); + let ml_dsa_65_block = make_authenticated_starfish_block(&committee, &ml_dsa_65); + + assert_eq!(ed_block.reference(), mac_block.reference()); + assert_eq!(ed_block.reference(), ml_dsa_44_block.reference()); + assert_eq!(ed_block.reference(), ml_dsa_65_block.reference()); + assert_ne!(ed_block.authentication(), mac_block.authentication()); + assert_ne!(ed_block.authentication(), ml_dsa_44_block.authentication()); + assert_ne!(ed_block.authentication(), ml_dsa_65_block.authentication()); + } + + #[test] + fn all_authentication_schemes_verify_for_starfish_protocols() { + let committee = Committee::new_for_benchmarks(4); + let ed_signers = Signer::new_for_test(committee.len()); + let ml_dsa_44_signers = crypto::MlDsa44Signer::new_for_test(committee.len()); + let ml_dsa_65_signers = crypto::MlDsa65Signer::new_for_test(committee.len()); + let mac_keyrings = crypto::mac_keyrings_for_test(committee.len()); + + for consensus_protocol in [ + ConsensusProtocol::Starfish, + ConsensusProtocol::StarfishSpeed, + ConsensusProtocol::SparseStarfishSpeed, + ] { + // A Sparse-Starfish-Speed non-leader has compressed references, + // while its leader carries the full frontier used by this test + // block. Using the round-one leader makes the same authenticated + // content structurally valid under all three protocols. + let author = committee.elect_leader(1) as usize; + let cases = [ + ( + make_authenticated_starfish_block_for_author( + &committee, + author as AuthorityIndex, + &BlockAuthorizer::Ed25519(&ed_signers[author]), + ), + BlockAuthenticationScheme::Ed25519, + ), + ( + make_authenticated_starfish_block_for_author( + &committee, + author as AuthorityIndex, + &BlockAuthorizer::MacVector(&mac_keyrings[author]), + ), + BlockAuthenticationScheme::MacVector, + ), + ( + make_authenticated_starfish_block_for_author( + &committee, + author as AuthorityIndex, + &BlockAuthorizer::MlDsa44(&ml_dsa_44_signers[author]), + ), + BlockAuthenticationScheme::MlDsa44, + ), + ( + make_authenticated_starfish_block_for_author( + &committee, + author as AuthorityIndex, + &BlockAuthorizer::MlDsa65(&ml_dsa_65_signers[author]), + ), + BlockAuthenticationScheme::MlDsa65, + ), + ]; + for (block, scheme) in &cases { + for (receiver, receiver_keys) in mac_keyrings.iter().enumerate() { + let mut received = block.clone(); + let mut encoder = Encoder::new(2, 4, 2).unwrap(); + let mac_keys = if *scheme == BlockAuthenticationScheme::MacVector { + receiver_keys.as_slice() + } else { + &[] + }; + received + .verify_with_authentication( + &committee, + receiver, + 0, + &mut encoder, + consensus_protocol, + *scheme, + mac_keys, + ) + .unwrap(); + } + } + } + } + + #[test] + fn mac_vector_uses_flat_binary_encoding() { + let committee = Committee::new_for_benchmarks(10); + let keyrings = crypto::mac_keyrings_for_test(committee.len()); + let block = make_authenticated_starfish_block( + &committee, + &BlockAuthorizer::MacVector(&keyrings[0]), + ); + + let encoded = bincode::serialize(block.authentication()).unwrap(); + let expected_size = 4 + 8 + committee.len() * crypto::MAC_TAG_SIZE; + assert_eq!(encoded.len(), expected_size); + + let decoded: BlockAuthentication = bincode::deserialize(&encoded).unwrap(); + assert_eq!(decoded, *block.authentication()); + + let yaml = serde_yaml::to_string(block.authentication()).unwrap(); + let decoded_yaml: BlockAuthentication = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(decoded_yaml, *block.authentication()); + } + + #[test] + fn relay_selects_only_the_destination_mac() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = crypto::mac_keyrings_for_test(committee.len()); + let block = make_authenticated_starfish_block( + &committee, + &BlockAuthorizer::MacVector(&keyrings[0]), + ); + let BlockAuthentication::MacVector(full_vector) = block.authentication() else { + panic!("expected full MAC vector") + }; + + let mut relayed = block.with_recipient_mac(2).unwrap(); + let BlockAuthentication::MacTag(tag) = relayed.authentication() else { + panic!("expected recipient MAC tag") + }; + assert_eq!(*tag, full_vector[2]); + assert_eq!(relayed.reference(), block.reference()); + assert_eq!( + bincode::serialize(relayed.authentication()).unwrap().len(), + 4 + 8 + crypto::MAC_TAG_SIZE, + ); + + let mut encoder = Encoder::new(2, 4, 2).unwrap(); + relayed + .verify_with_authentication( + &committee, + 2, + 1, + &mut encoder, + ConsensusProtocol::Starfish, + BlockAuthenticationScheme::MacVector, + &keyrings[2], + ) + .unwrap(); + + let mut wrong_recipient = block.with_recipient_mac(2).unwrap(); + assert!( + wrong_recipient + .verify_with_authentication( + &committee, + 1, + 2, + &mut encoder, + ConsensusProtocol::Starfish, + BlockAuthenticationScheme::MacVector, + &keyrings[1], + ) + .is_err() + ); + assert!(relayed.with_recipient_mac(3).is_none()); + } + + #[test] + fn mac_verification_authenticates_the_claimed_block_author() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = crypto::mac_keyrings_for_test(committee.len()); + let mut correctly_authenticated = make_authenticated_starfish_block_for_author( + &committee, + 1, + &BlockAuthorizer::MacVector(&keyrings[1]), + ); + let mut encoder = Encoder::new(2, 4, 2).unwrap(); + correctly_authenticated + .verify_with_authentication( + &committee, + 2, + 1, + &mut encoder, + ConsensusProtocol::Starfish, + BlockAuthenticationScheme::MacVector, + &keyrings[2], + ) + .unwrap(); + + // The content claims authority 1, but authority 0's pairwise keys + // produced the vector. Recipient 2 must reject it when selecting the + // key associated with the claimed author. + let mut wrong_author_keys = make_authenticated_starfish_block_for_author( + &committee, + 1, + &BlockAuthorizer::MacVector(&keyrings[0]), + ); + assert!( + wrong_author_keys + .verify_with_authentication( + &committee, + 2, + 1, + &mut encoder, + ConsensusProtocol::Starfish, + BlockAuthenticationScheme::MacVector, + &keyrings[2], + ) + .is_err() + ); + } + + #[test] + fn mac_vector_verification_is_limited_to_the_receivers_own_tag() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = crypto::mac_keyrings_for_test(committee.len()); + let block = make_authenticated_starfish_block( + &committee, + &BlockAuthorizer::MacVector(&keyrings[0]), + ); + let mut tampered = block.clone(); + let BlockAuthentication::MacVector(tags) = &mut tampered.header.authentication else { + panic!("expected full MAC vector") + }; + tags[3] = MacTag::from_bytes([0; crypto::MAC_TAG_SIZE]); + + let mut receiver_one = tampered.clone(); + let mut encoder = Encoder::new(2, 4, 2).unwrap(); + receiver_one + .verify_with_authentication( + &committee, + 1, + 0, + &mut encoder, + ConsensusProtocol::Starfish, + BlockAuthenticationScheme::MacVector, + &keyrings[1], + ) + .unwrap(); + + assert!( + tampered + .verify_with_authentication( + &committee, + 3, + 0, + &mut encoder, + ConsensusProtocol::Starfish, + BlockAuthenticationScheme::MacVector, + &keyrings[3], + ) + .is_err() + ); + } + + #[test] + fn same_block_merge_keeps_full_mac_and_transaction_data_in_either_order() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = crypto::mac_keyrings_for_test(committee.len()); + let full = make_authenticated_starfish_block( + &committee, + &BlockAuthorizer::MacVector(&keyrings[0]), + ); + let mut tagged_with_transactions = full.with_recipient_mac(1).unwrap(); + tagged_with_transactions.transaction_data = + Some(TransactionData::new(vec![BaseTransaction::Share( + Transaction::new(vec![1, 2, 3]), + )])); + + let tag_then_full = tagged_with_transactions.merge_same_block(&full).unwrap(); + assert!(tag_then_full.has_full_mac_vector()); + assert!(tag_then_full.has_transaction_data()); + + let full_then_tag = full + .as_header_only() + .merge_same_block(&tagged_with_transactions) + .unwrap(); + assert!(full_then_tag.has_full_mac_vector()); + assert!(full_then_tag.has_transaction_data()); + } + + #[test] + fn flat_mac_vector_rejects_partial_tags() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = crypto::mac_keyrings_for_test(committee.len()); + let block = make_authenticated_starfish_block( + &committee, + &BlockAuthorizer::MacVector(&keyrings[0]), + ); + + let mut encoded = bincode::serialize(block.authentication()).unwrap(); + let invalid_payload_len = committee.len() * crypto::MAC_TAG_SIZE - 1; + encoded[4..12].copy_from_slice(&(invalid_payload_len as u64).to_le_bytes()); + encoded.truncate(12 + invalid_payload_len); + + assert!(bincode::deserialize::(&encoded).is_err()); + } + + #[test] + fn rejects_incomplete_mac_vector_and_wrong_authentication_scheme() { + let committee = Committee::new_for_benchmarks(4); + let keyrings = crypto::mac_keyrings_for_test(committee.len()); + let mut mac_block = make_authenticated_starfish_block( + &committee, + &BlockAuthorizer::MacVector(&keyrings[0]), + ); + let BlockAuthentication::MacVector(tags) = &mut mac_block.header.authentication else { + panic!("expected MAC vector") + }; + tags.pop(); + let mut encoder = Encoder::new(2, 4, 2).unwrap(); + assert!( + mac_block + .verify_with_authentication( + &committee, + 1, + 0, + &mut encoder, + ConsensusProtocol::Starfish, + BlockAuthenticationScheme::MacVector, + &keyrings[1], + ) + .is_err() + ); + + let mut wrong_tag_block = make_authenticated_starfish_block( + &committee, + &BlockAuthorizer::MacVector(&keyrings[0]), + ); + let BlockAuthentication::MacVector(tags) = &mut wrong_tag_block.header.authentication + else { + panic!("expected MAC vector") + }; + tags.swap(1, 2); + assert!( + wrong_tag_block + .verify_with_authentication( + &committee, + 1, + 0, + &mut encoder, + ConsensusProtocol::Starfish, + BlockAuthenticationScheme::MacVector, + &keyrings[1], + ) + .is_err() + ); + + let ed_signers = Signer::new_for_test(committee.len()); + let mut ed_block = make_authenticated_starfish_block( + &committee, + &BlockAuthorizer::Ed25519(&ed_signers[0]), + ); + assert!( + ed_block + .verify_with_authentication( + &committee, + 1, + 0, + &mut encoder, + ConsensusProtocol::Starfish, + BlockAuthenticationScheme::MlDsa44, + &[], + ) + .is_err() + ); + } + fn single_signer_cert( digest: [u8; 32], signer: AuthorityIndex, @@ -2346,6 +3314,133 @@ mod tests { assert_eq!(block.acknowledgments(), vec![c, d]); } + #[test] + fn starfish_rbc_header_uses_canonical_content_identity_without_authentication() { + let parents = vec![ + BlockReference::new_test(0, 1), + BlockReference::new_test(1, 1), + BlockReference::new_test(2, 1), + ]; + let extra = BlockReference::new_test(3, 1); + let raw_acknowledgments = vec![extra, parents[2]]; + let block = VerifiedBlock::new_starfish_rbc( + 0, + 2, + parents.clone(), + raw_acknowledgments, + 17, + Vec::new(), + None, + ); + + assert_eq!(block.authentication(), &BlockAuthentication::None); + assert_eq!(block.acknowledgments(), vec![parents[2], extra]); + let commitment = block + .header() + .transactions_commitment + .expect("RBC header must commit its Starfish payload"); + assert_eq!( + block.digest(), + BlockDigest::new_starfish_rbc_header( + 0, + 2, + &parents, + &[parents[2], extra], + 17, + commitment, + ) + ); + } + + #[test] + fn single_dag_rbc_uses_one_versioned_block_identity_and_canonical_evidence() { + let committee = Committee::new_for_benchmarks(4); + let parents: Vec<_> = committee + .authorities() + .map(|authority| BlockReference::new_test(authority, 1)) + .collect(); + let echo = StarfishRbcReferenceV3::new( + StarfishRbcReferenceKindV3::Echo, + BlockReference::new_test(3, 1), + ); + let ready = StarfishRbcReferenceV3::new( + StarfishRbcReferenceKindV3::Ready, + BlockReference::new_test(1, 1), + ); + let v3 = VerifiedBlock::new_starfish_rbc_single_dag( + 0, + 2, + parents.clone(), + Vec::new(), + 17, + Vec::new(), + None, + StarfishRbcFieldsV3::new(vec![ready, echo]), + ); + let v3_reordered = VerifiedBlock::new_starfish_rbc_single_dag( + 0, + 2, + parents.clone(), + Vec::new(), + 17, + Vec::new(), + None, + StarfishRbcFieldsV3::new(vec![echo, ready]), + ); + let direct = + VerifiedBlock::new_starfish_rbc(0, 2, parents, Vec::new(), 17, Vec::new(), None); + + assert_eq!(v3.reference(), v3_reordered.reference()); + assert_ne!(v3.reference(), direct.reference()); + let canonical = RbcCanonicalHeader::from_block_header(v3.header()).unwrap(); + canonical.validate_for_committee(&committee).unwrap(); + assert_eq!(canonical.reference(), *v3.reference()); + assert_eq!( + canonical.starfish_rbc_v3().unwrap().references(), + &[echo, ready] + ); + } + + #[test] + fn starfish_rbc_header_verification_is_content_only() { + let committee = Committee::new_for_benchmarks(4); + let parents: Vec<_> = committee + .authorities() + .map(|authority| BlockReference::new_test(authority, 0)) + .collect(); + let mut block = + VerifiedBlock::new_starfish_rbc(0, 1, parents, Vec::new(), 19, Vec::new(), None); + let mut encoder = Encoder::new(2, 4, 2).unwrap(); + assert!( + block + .verify_with_authentication( + &committee, + 0, + 0, + &mut encoder, + ConsensusProtocol::StarfishRbc, + BlockAuthenticationScheme::Ed25519, + &[], + ) + .is_ok() + ); + + block.header.authentication = BlockAuthentication::Ed25519(SignatureBytes::default()); + assert!( + block + .verify_with_authentication( + &committee, + 0, + 0, + &mut encoder, + ConsensusProtocol::StarfishRbc, + BlockAuthenticationScheme::Ed25519, + &[], + ) + .is_err() + ); + } + #[test] fn falls_back_to_legacy_ack_encoding_when_suffix_index_exceeds_u8() { let block_references: Vec<_> = (0..300) @@ -2368,7 +3463,7 @@ mod tests { reference: BlockReference::new_test(0, 2), block_references: vec![a], meta_creation_time_ns: 0, - signature: SignatureBytes::default(), + authentication: BlockAuthentication::None, transactions_commitment: None, ack: Some(AckFields { intersection: None, @@ -2378,6 +3473,7 @@ mod tests { bls: None, sailfish: None, unprovable_certificate: None, + starfish_rbc_v3: None, serialized: None, }; diff --git a/crates/starfish-core/src/validator.rs b/crates/starfish-core/src/validator.rs index 252edf9d..ef42a7ae 100644 --- a/crates/starfish-core/src/validator.rs +++ b/crates/starfish-core/src/validator.rs @@ -18,14 +18,15 @@ use crate::{ committee::Committee, config::{NodePrivateConfig, NodePublicConfig, Parameters}, core::Core, - dag_state::{ConsensusProtocol, DagState}, + dag_state::{DagState, ProtocolConfig}, metrics::{MetricReporter, Metrics}, net_sync::NetworkSyncer, network::Network, prometheus, runtime::{JoinError, JoinHandle}, + starfish_rbc::RbcProtocolInstanceId, transactions_generator::TransactionGenerator, - types::{AuthorityIndex, PartialSig}, + types::{AuthorityIndex, BlockAuthenticationScheme, PartialSig}, }; pub struct Validator { @@ -45,6 +46,80 @@ impl Validator { byzantine_strategy: String, consensus: String, ) -> Result { + let protocol_config = ProtocolConfig::from_selection( + &consensus, + public_config.parameters.block_authentication.as_deref(), + ) + .map_err(|error| eyre!(error))?; + let is_starfish_rbc = protocol_config.consensus_protocol.is_starfish_rbc(); + if public_config + .parameters + .starfish_rbc_single_dag_echo_qc_fast_path + && !protocol_config + .consensus_protocol + .is_starfish_rbc_single_dag() + { + return Err(eyre!( + "Starfish-RBC single-DAG ECHO-QC fast path requires consensus \ + 'starfish-rbc-single-dag'" + )); + } + if public_config + .parameters + .starfish_rbc_single_dag_echo_qc_fast_path + && parameters.benchmark_duration.is_none() + { + return Err(eyre!( + "Starfish-RBC single-DAG ECHO-QC fast path is restricted to finite testbed \ + benchmarks" + )); + } + if is_starfish_rbc { + let protocol_instance = public_config + .parameters + .starfish_rbc_protocol_instance + .ok_or_else(|| eyre!("Starfish-RBC protocol instance is missing"))?; + RbcProtocolInstanceId::new(protocol_instance).map_err(|error| eyre!(error))?; + } + if (is_starfish_rbc + || protocol_config.block_authentication_scheme == BlockAuthenticationScheme::MacVector) + && private_config.mac_keys.len() != committee.len() + { + return Err(eyre!( + "MAC keyring length {} does not match committee size {}", + private_config.mac_keys.len(), + committee.len(), + )); + } + match protocol_config.block_authentication_scheme { + BlockAuthenticationScheme::Ed25519 => { + if committee.get_public_key(authority) != Some(&private_config.keypair.public_key()) + { + return Err(eyre!( + "Ed25519 private key does not match committee authority {authority}" + )); + } + } + BlockAuthenticationScheme::MacVector => {} + BlockAuthenticationScheme::MlDsa44 => { + if committee.get_ml_dsa_44_public_key(authority) + != Some(&private_config.ml_dsa_44_keypair.public_key()) + { + return Err(eyre!( + "ML-DSA-44 private key does not match committee authority {authority}" + )); + } + } + BlockAuthenticationScheme::MlDsa65 => { + if committee.get_ml_dsa_65_public_key(authority) + != Some(&private_config.ml_dsa_65_keypair.public_key()) + { + return Err(eyre!( + "ML-DSA-65 private key does not match committee authority {authority}" + )); + } + } + } // Network and metrics setup remains the same let network_address = public_config .network_address(authority) @@ -76,7 +151,7 @@ impl Validator { .register(Box::new(pc)) .wrap_err("Failed to register ProcessCollector")?; } - let protocol = ConsensusProtocol::from_str(&consensus); + let protocol = protocol_config.consensus_protocol; let resolved_dissemination = protocol.resolve_dissemination_mode(public_config.parameters.dissemination_mode); let dissemination_str = resolved_dissemination.to_string(); @@ -101,13 +176,13 @@ impl Validator { // Open the DAG state. let rocks_path = private_config.rocksdb(); - let recovered = DagState::open( + let recovered = DagState::open_with_protocol_config( authority, rocks_path, metrics.clone(), committee.clone(), byzantine_strategy, - consensus, + protocol_config, ¶meters.storage_backend, public_config .parameters @@ -176,7 +251,8 @@ impl Validator { partial_sig_rx, bls_cert_aggregator, bls_signer_for_service, - ); + ) + .await; tracing::info!("Validator {authority} listening on {network_address}"); tracing::info!("Validator {authority} exposing metrics on {metrics_address}"); @@ -261,11 +337,21 @@ mod smoke_tests { } } - async fn run_commit_test(consensus: &str, port_offset: u16) { + async fn run_commit_test( + consensus: &str, + block_authentication: Option<&str>, + port_offset: u16, + ) { let committee_size = 4; let committee = Committee::new_for_benchmarks(committee_size); - let public_config = + let mut public_config = NodePublicConfig::new_for_tests(committee_size).with_port_offset(port_offset); + public_config.parameters.block_authentication = block_authentication.map(str::to_string); + if matches!(consensus, "starfish-rbc" | "starfish-rbc-single-dag") { + public_config + .parameters + .refresh_starfish_rbc_protocol_instance(); + } let parameters = Parameters::default(); let dir = TempDir::new().unwrap(); @@ -294,7 +380,16 @@ mod smoke_tests { .all_metric_addresses() .map(|a| a.to_owned()) .collect(); - let timeout = config::param_defaults::default_leader_timeout() * 5; + // Four RBC authentication variants run in parallel in the full test + // suite and include expensive ML-DSA signing. Give that composed flow + // enough scheduling headroom without relaxing existing protocols. + let timeout_multiplier = if matches!(consensus, "starfish-rbc" | "starfish-rbc-single-dag") + { + 20 + } else { + 5 + }; + let timeout = config::param_defaults::default_leader_timeout() * timeout_multiplier; tokio::select! { _ = await_for_commits(addresses) => (), @@ -309,30 +404,98 @@ mod smoke_tests { } } - #[test_case("mysticeti", 0)] - #[test_case("cordial-miners", 40)] - #[test_case("starfish", 60)] - #[test_case("starfish-speed", 80)] - #[test_case("starfish-bls", 100)] - #[test_case("sailfish++", 120)] - #[test_case("bluestreak", 140)] - #[test_case("mysticeti-bls", 160)] - #[test_case("sparse-starfish-speed", 180)] + #[test_case("mysticeti", None, 0)] + #[test_case("mysticeti", Some("ml-dsa-65"), 1280)] + #[test_case("cordial-miners", None, 40)] + #[test_case("cordial-miners", Some("ml-dsa-65"), 1300)] + #[test_case("starfish", None, 60)] + #[test_case("starfish-mac", None, 700)] + #[test_case("starfish", Some("ml-dsa-44"), 720)] + #[test_case("starfish", Some("ml-dsa-65"), 1000)] + #[test_case("starfish-speed", None, 80)] + #[test_case("starfish-speed-mac", None, 760)] + #[test_case("starfish-speed", Some("ml-dsa-44"), 780)] + #[test_case("starfish-speed", Some("ml-dsa-65"), 1040)] + #[test_case("starfish-bls", None, 100)] + #[test_case("starfish-bls", Some("ml-dsa-65"), 1320)] + #[test_case("sailfish++", None, 120)] + #[test_case("sailfish++", Some("ml-dsa-65"), 1340)] + #[test_case("bluestreak", None, 140)] + #[test_case("mysticeti-bls", None, 160)] + #[test_case("mysticeti-bls", Some("ml-dsa-65"), 1360)] + #[test_case("sparse-starfish-speed", None, 180)] + #[test_case("sparse-starfish-speed-mac", None, 840)] + #[test_case("sparse-starfish-speed", Some("ml-dsa-44"), 860)] + #[test_case("sparse-starfish-speed", Some("ml-dsa-65"), 1080)] + #[test_case("bluestreak-mac", None, 920)] + #[test_case("bluestreak", Some("ml-dsa-44"), 940)] + #[test_case("bluestreak", Some("ml-dsa-65"), 1120)] + #[test_case("starfish-rbc", None, 1400)] + #[test_case("starfish-rbc", Some("mac"), 1440)] + #[test_case("starfish-rbc", Some("ml-dsa-44"), 1480)] + #[test_case("starfish-rbc", Some("ml-dsa-65"), 1520)] + #[test_case("starfish-rbc-single-dag", Some("mac"), 1640)] #[tokio::test] - async fn validator_commit(consensus: &str, port_offset: u16) { - run_commit_test(consensus, port_offset).await; + async fn validator_commit( + consensus: &str, + block_authentication: Option<&str>, + port_offset: u16, + ) { + run_commit_test(consensus, block_authentication, port_offset).await; } #[tokio::test] async fn validator_commit_bluestreak_basic() { - run_commit_test("bluestreak", 150).await; + run_commit_test("bluestreak", None, 150).await; } - async fn run_sync_test(consensus: &str, port_offset: u16) { + #[tokio::test] + async fn starfish_rbc_single_validator_starts_on_current_thread_runtime() { + let committee_size = 4; + // Give the sole running validator quorum stake so startup immediately + // exercises local RBC proposal construction before any peer connects. + let committee = Committee::new_test(vec![100, 1, 1, 1]); + let mut public_config = + NodePublicConfig::new_for_tests(committee_size).with_port_offset(1600); + public_config.parameters.block_authentication = Some("mac".to_string()); + public_config + .parameters + .refresh_starfish_rbc_protocol_instance(); + + let dir = TempDir::new().unwrap(); + let private_config = + NodePrivateConfig::new_for_benchmarks(dir.as_ref(), committee_size).remove(0); + fs::create_dir_all(&private_config.storage_path).unwrap(); + + let validator = time::timeout( + Duration::from_secs(5), + Validator::start( + 0, + committee, + public_config, + private_config, + Parameters::default(), + "honest".to_string(), + "starfish-rbc".to_string(), + ), + ) + .await + .expect("Starfish-RBC startup must not block its async runtime") + .unwrap(); + validator.stop().await; + } + + async fn run_sync_test(consensus: &str, block_authentication: Option<&str>, port_offset: u16) { let committee_size = 4; let committee = Committee::new_for_benchmarks(committee_size); - let public_config = + let mut public_config = NodePublicConfig::new_for_tests(committee_size).with_port_offset(port_offset); + public_config.parameters.block_authentication = block_authentication.map(str::to_string); + if matches!(consensus, "starfish-rbc" | "starfish-rbc-single-dag") { + public_config + .parameters + .refresh_starfish_rbc_protocol_instance(); + } let parameters = Parameters::default(); let dir = TempDir::new().unwrap(); @@ -409,18 +572,32 @@ mod smoke_tests { } } - #[test_case("mysticeti", 100)] - #[test_case("cordial-miners", 140)] - #[test_case("starfish", 160)] - #[test_case("starfish-speed", 180)] - #[test_case("starfish-bls", 200)] - #[test_case("sailfish++", 220)] - #[test_case("bluestreak", 260)] - #[test_case("mysticeti-bls", 280)] - #[test_case("sparse-starfish-speed", 320)] + #[test_case("mysticeti", None, 100)] + #[test_case("cordial-miners", None, 140)] + #[test_case("starfish", None, 160)] + #[test_case("starfish-mac", None, 740)] + #[test_case("starfish", Some("ml-dsa-44"), 1020)] + #[test_case("starfish", Some("ml-dsa-65"), 1200)] + #[test_case("starfish-speed", None, 180)] + #[test_case("starfish-speed-mac", None, 800)] + #[test_case("starfish-speed", Some("ml-dsa-44"), 820)] + #[test_case("starfish-speed", Some("ml-dsa-65"), 1220)] + #[test_case("starfish-bls", None, 200)] + #[test_case("sailfish++", None, 220)] + #[test_case("bluestreak", None, 260)] + #[test_case("mysticeti-bls", None, 280)] + #[test_case("sparse-starfish-speed", None, 320)] + #[test_case("sparse-starfish-speed-mac", None, 880)] + #[test_case("sparse-starfish-speed", Some("ml-dsa-44"), 900)] + #[test_case("sparse-starfish-speed", Some("ml-dsa-65"), 1240)] + #[test_case("bluestreak-mac", None, 960)] + #[test_case("bluestreak", Some("ml-dsa-44"), 980)] + #[test_case("bluestreak", Some("ml-dsa-65"), 1260)] + #[test_case("starfish-rbc", Some("mac"), 1560)] + #[test_case("starfish-rbc-single-dag", Some("mac"), 1660)] #[tokio::test] - async fn validator_sync(consensus: &str, port_offset: u16) { - run_sync_test(consensus, port_offset).await; + async fn validator_sync(consensus: &str, block_authentication: Option<&str>, port_offset: u16) { + run_sync_test(consensus, block_authentication, port_offset).await; } async fn run_crash_faults_test(consensus: &str, port_offset: u16) { diff --git a/crates/starfish/src/main.rs b/crates/starfish/src/main.rs index 2c8e24e5..cdd275ca 100644 --- a/crates/starfish/src/main.rs +++ b/crates/starfish/src/main.rs @@ -65,8 +65,13 @@ enum Operation { parameters_path: String, #[clap(long, value_name = "STRING", default_value = "")] byzantine_strategy: String, + /// Consensus protocol. The `*-mac` names are experimental protocols. #[clap(long, value_name = "STRING", default_value = "starfish")] consensus: String, + /// Block signature scheme. Defaults to Ed25519 and is not applicable + /// to the experimental `*-mac` protocols. + #[clap(long, value_name = "ed25519|ml-dsa-44|ml-dsa-65|mac")] + block_authentication: Option, }, /// Deploy a local validator for test. Dryrun mode uses /// default keys and committee configurations. @@ -93,8 +98,13 @@ enum Operation { /// `--adversarial-latency` is enabled (0-100). #[clap(long, value_name = "INT", default_value_t = 34)] adversarial_latency_percent: u32, + /// Consensus protocol. The `*-mac` names are experimental protocols. #[clap(long, value_name = "STRING", default_value = "starfish")] consensus: String, + /// Block signature scheme. Defaults to Ed25519 and is not applicable + /// to the experimental `*-mac` protocols. + #[clap(long, value_name = "ed25519|ml-dsa-44|ml-dsa-65|mac")] + block_authentication: Option, /// Directory to store validator data (default: current directory) #[clap(long, value_name = "PATH")] data_dir: Option, @@ -143,8 +153,19 @@ enum Operation { /// `--adversarial-latency` is enabled (0-100). #[clap(long, value_name = "INT", default_value_t = 34)] adversarial_latency_percent: u32, + /// Consensus protocol. The `*-mac` names are experimental protocols. #[clap(long, value_name = "STRING", default_value = "starfish")] consensus: String, + /// Block signature scheme. Defaults to Ed25519 and is not applicable + /// to the experimental `*-mac` protocols. + #[clap(long, value_name = "ed25519|ml-dsa-44|ml-dsa-65|mac")] + block_authentication: Option, + /// Testbed-only: deliver a single-DAG RBC header after a receiver-local + /// quorum ECHO. This preserves uniqueness but not Byzantine + /// selective-withholding totality, so it is restricted to finite + /// benchmark runs. + #[clap(long, default_value_t = false)] + starfish_rbc_single_dag_echo_qc_fast_path: bool, #[clap(long, value_name = "INT", default_value_t = 600)] duration_secs: u64, /// Dissemination mode override: @@ -178,6 +199,7 @@ async fn main() -> Result<()> { parameters_path, byzantine_strategy, consensus: consensus_protocol, + block_authentication, } => { run( authority, @@ -187,6 +209,7 @@ async fn main() -> Result<()> { parameters_path, byzantine_strategy, consensus_protocol, + block_authentication, ) .await? } @@ -200,6 +223,7 @@ async fn main() -> Result<()> { adversarial_latency, adversarial_latency_percent, consensus: consensus_protocol, + block_authentication, data_dir, base_ip, storage_backend, @@ -218,6 +242,7 @@ async fn main() -> Result<()> { adversarial_latency, adversarial_latency_percent, consensus_protocol, + block_authentication, data_dir, base_ip, storage_backend, @@ -238,6 +263,8 @@ async fn main() -> Result<()> { adversarial_latency, adversarial_latency_percent, consensus: consensus_protocol, + block_authentication, + starfish_rbc_single_dag_echo_qc_fast_path, duration_secs, dissemination_mode, } => { @@ -247,6 +274,12 @@ async fn main() -> Result<()> { } node_parameters.adversarial_latency = adversarial_latency; node_parameters.adversarial_latency_percent = adversarial_latency_percent; + node_parameters.block_authentication = block_authentication; + node_parameters.starfish_rbc_single_dag_echo_qc_fast_path = + starfish_rbc_single_dag_echo_qc_fast_path; + if is_starfish_rbc_selection(&consensus_protocol) { + node_parameters.refresh_starfish_rbc_protocol_instance(); + } if let Some(ref mode) = dissemination_mode { node_parameters.dissemination_mode = parse_dissemination_mode(mode)?; } @@ -287,13 +320,16 @@ fn benchmark_genesis( tracing::info!("Generated committee file: {}", committee_path.display()); // Generate the public node config file. - let node_parameters = match node_parameters_path { + let mut node_parameters = match node_parameters_path { Some(path) => NodeParameters::load(&path).wrap_err(format!( "Failed to load parameters file '{}'", path.display() ))?, None => NodeParameters::default(), }; + if node_parameters.starfish_rbc_protocol_instance.is_none() { + node_parameters.refresh_starfish_rbc_protocol_instance(); + } let node_public_config = NodePublicConfig::new_for_benchmarks(ips, Some(node_parameters)); let mut node_public_config_path = working_directory.clone(); @@ -339,6 +375,22 @@ async fn local_benchmark( } println!("Transaction Load: {load} tx/s"); println!("Consensus Protocol: {consensus_protocol}"); + println!( + "Block Authentication: {}", + if consensus_protocol.ends_with("-mac") { + "mac-vector (experimental)" + } else { + node_parameters + .block_authentication + .as_deref() + .unwrap_or("ed25519") + } + ); + if node_parameters.starfish_rbc_single_dag_echo_qc_fast_path { + println!( + "Single-DAG receiver-local quorum-ECHO: ENABLED (signature-free latency lower bound; Byzantine totality not provided)" + ); + } if let Some(latency) = node_parameters.uniform_latency_ms { println!("Network Latency: {latency} ms (uniform)"); } else { @@ -364,7 +416,10 @@ async fn local_benchmark( let ips = vec![IpAddr::V4(Ipv4Addr::LOCALHOST); committee_size]; let committee = Committee::new_for_benchmarks(committee_size); load /= committee.len(); - let parameters = Parameters::almost_default(load); + let mut parameters = Parameters::almost_default(load); + // Marks this explicitly bounded local run as a testbed execution. The + // receiver-local quorum-ECHO path is rejected when no finite bound exists. + parameters.benchmark_duration = Some(Duration::from_secs(duration_secs)); // Equivocating Byzantine strategies must not generate transactions. let byzantine_parameters = if ByzantineStrategy::from_strategy_str(&byzantine_strategy) .is_some_and(|s| s.is_equivocating()) @@ -511,14 +566,18 @@ async fn run( parameters_path: String, byzantine_strategy: String, consensus_protocol: String, + block_authentication: Option, ) -> Result<()> { tracing::info!("Starting node {authority}"); let committee = Committee::load(&committee_path) .wrap_err(format!("Failed to load committee file '{committee_path}'"))?; - let public_config = NodePublicConfig::load(&public_config_path).wrap_err(format!( + let mut public_config = NodePublicConfig::load(&public_config_path).wrap_err(format!( "Failed to load parameters file '{public_config_path}'" ))?; + if block_authentication.is_some() { + public_config.parameters.block_authentication = block_authentication; + } let private_config = NodePrivateConfig::load(&private_config_path).wrap_err(format!( "Failed to load private configuration file '{private_config_path}'" ))?; @@ -555,6 +614,7 @@ async fn dryrun( adversarial_latency: bool, adversarial_latency_percent: u32, consensus_protocol: String, + block_authentication: Option, data_dir: Option, base_ip: Option, storage_backend: Option, @@ -596,6 +656,7 @@ async fn dryrun( node_parameters.adversarial_latency = adversarial_latency; node_parameters.adversarial_latency_percent = adversarial_latency_percent; node_parameters.compress_network = compress_network; + node_parameters.block_authentication = block_authentication; if let Some(workers) = bls_workers { node_parameters.bls_verification_workers = workers; } @@ -645,6 +706,12 @@ async fn dryrun( Ok(()) } +fn is_starfish_rbc_selection(consensus_protocol: &str) -> bool { + matches!( + consensus_protocol, + "starfish-rbc" | "starfish-rbc-single-dag" + ) +} fn ipv4_add_offset(base: Ipv4Addr, offset: usize) -> Result { let offset = u32::try_from(offset).context("validator count exceeds IPv4 offset range")?; let next = u32::from(base) @@ -704,7 +771,9 @@ pub fn default_table_format() -> format::TableFormat { mod tests { use std::net::Ipv4Addr; - use super::ipv4_add_offset; + use clap::Parser; + + use super::{Args, Operation, ipv4_add_offset}; #[test] fn ipv4_add_offset_crosses_octet_boundary() { @@ -720,4 +789,61 @@ mod tests { let base = Ipv4Addr::new(255, 255, 255, 255); assert!(ipv4_add_offset(base, 1).is_err()); } + + #[test] + fn dry_run_parses_block_authentication_separately_from_consensus() { + let args = Args::try_parse_from([ + "starfish", + "dry-run", + "--authority", + "0", + "--committee-size", + "4", + "--consensus", + "mysticeti", + "--block-authentication", + "ml-dsa-65", + ]) + .unwrap(); + + let Operation::DryRun { + consensus, + block_authentication, + .. + } = args.operation + else { + panic!("expected dry-run operation"); + }; + assert_eq!(consensus, "mysticeti"); + assert_eq!(block_authentication.as_deref(), Some("ml-dsa-65")); + } + + #[test] + fn local_benchmark_parses_starfish_rbc_mac_authentication() { + let args = Args::try_parse_from([ + "starfish", + "local-benchmark", + "--committee-size", + "4", + "--consensus", + "starfish-rbc-single-dag", + "--block-authentication", + "mac", + "--starfish-rbc-single-dag-echo-qc-fast-path", + ]) + .unwrap(); + + let Operation::LocalBenchmark { + consensus, + block_authentication, + starfish_rbc_single_dag_echo_qc_fast_path, + .. + } = args.operation + else { + panic!("expected local-benchmark operation"); + }; + assert_eq!(consensus, "starfish-rbc-single-dag"); + assert_eq!(block_authentication.as_deref(), Some("mac")); + assert!(starfish_rbc_single_dag_echo_qc_fast_path); + } } diff --git a/deny.toml b/deny.toml index 3f76a6c3..8ce45edd 100644 --- a/deny.toml +++ b/deny.toml @@ -28,7 +28,7 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", - "GPL-2.0", + "GPL-2.0-or-later", "ISC", "LicenseRef-ring", "MIT", @@ -63,6 +63,14 @@ name = "minibytes" expression = "MIT" license-files = [{ path = "LICENSE", hash = 0x6b2d3210 }] +# bloom 0.3.2 uses the deprecated SPDX identifier `GPL-2.0`; its license +# grants redistribution under GPL version 2 or any later version. +[[licenses.clarify]] +name = "bloom" +version = "0.3.2" +expression = "GPL-2.0-or-later" +license-files = [{ path = "LICENSE", hash = 0xeaa66bfd }] + [licenses.private] # If true, ignores workspace crates that aren't published, or are only # published to private registries. @@ -90,6 +98,10 @@ skip = [ # tidehunter uses prometheus 0.14 (protobuf 3.x), starfish-core uses 0.13 (protobuf 2.x) { name = "prometheus", version = "0.13" }, { name = "protobuf", version = "2" }, + # ml-dsa 0.1 uses RustCrypto digest 0.11 and crypto-common 0.2 while + # existing workspace dependencies still require their previous releases. + { name = "crypto-common", version = "0.2" }, + { name = "digest", version = "0.11" }, ] skip-tree = [ # aws-smithy-http-client depends on both hyper 0.14 and 1.x, diff --git a/docs/starfish-rbc-protocol.md b/docs/starfish-rbc-protocol.md new file mode 100644 index 00000000..49809d2e --- /dev/null +++ b/docs/starfish-rbc-protocol.md @@ -0,0 +1,814 @@ +# Starfish-RBC protocol specification + +Status: integrated benchmark prototype; extended adversarial validation and performance comparison +pending + +This document specifies the first correctness-oriented prototype of Starfish with reliable header +certification and a signature-free MAC configuration. The provisional CLI name is `starfish-rbc`. + +The protocol is intentionally conservative. It reuses plain Starfish for transaction-data +availability, DAG ordering, and commitment, while adding a Bracha reliable-broadcast layer for +block headers. It uses the same validator, committee, networking, storage, orchestrator, and +benchmark setup as the other protocols in this repository. + +This document is not a claim that the composed protocol is already proved or fully implemented. The +motivating work-in-progress note gives reliable-delivery and MAC-vector ingredients, but does not +prove their composition with Starfish. The proof obligations below must be discharged before making +a safety or liveness claim. + +## 1. Goals + +The first prototype has five goals: + +1. Replace transferable block signatures with receiver-specific MAC authentication without + allowing a Byzantine author to make a block permanently acceptable to only part of the honest + committee. +2. Preserve `BlockReference.digest` as a hash of canonical block content only. Authentication + remains a sidecar and does not change block identity. +3. Certify only the Starfish header, including its transaction commitment. Transaction payload + availability remains the responsibility of Starfish's existing Reed-Solomon and acknowledgment + layer. +4. Keep the reliable-broadcast message flow identical while selecting Ed25519, ML-DSA-44, + ML-DSA-65, or a MAC vector for the author's initial header authentication. This gives an + apples-to-apples comparison. +5. Establish a correctness baseline before adding tree dissemination, fast paths, or early + uncertified consensus participation. + +The current `starfish-mac`, `starfish-speed-mac`, `sparse-starfish-speed-mac`, and +`bluestreak-mac` modes remain lower-bound benchmark experiments. Changing only their block +authentication does not turn them into complete Byzantine protocols. + +Conceptually, selection is represented as two independent fields rather than copied protocol +variants: + +```text +ProtocolConfig { + consensus: StarfishRbc, + initial_header_authentication: Ed25519 | MlDsa44 | MlDsa65 | Mac, +} +``` + +The consensus selector enables RBC and the clean-DAG rules. The authentication selector changes +only `HeaderProposal.initial_authentication`. + +## 2. Version-one scope + +Version one assumes: + +- a static committee for the duration of a run; +- Byzantine voting stake strictly below one third of total stake; +- reliable point-to-point communication after GST in the usual partially synchronous model; +- fixed pairwise MAC keys provisioned by the benchmark setup; +- honest validators remain online for the run; and +- no committee reconfiguration or epoch transition. + +Let `W` be total committee stake. Version one uses the repository's exact integer thresholds: + +```text +Q = floor(2W / 3) + 1 +V = floor(W / 3) + 1 +``` + +The transitions are `Q` ECHO stake to READY, `V` READY stake to READY, and `Q` READY stake to +delivery. For an equal-stake committee with `n = 3f + 1`, these are `2f + 1`, `f + 1`, and +`2f + 1`. Locally sent ECHO and READY actions count toward these thresholds. + +The following are out of scope for version one: + +- crash/restart safety and replay of locally observed phase evidence; +- production key establishment, rotation, or compromise recovery; +- BLS control certificates; +- Starfish-Speed, Sparse-Starfish-Speed, and Bluestreak integration; +- tree or bounded-fanout header dissemination; +- Sailfish's optimistic `VOTE`/fast-delivery path; +- the `DONE` reliable-delivery optimization; +- encoding ECHO and READY as DAG protocol blocks; and +- adversarial resource-exhaustion resistance beyond fixed message-size checks. + +These exclusions constrain the first implementation, not the eventual research direction. + +## 3. Protocol identity and authenticated statements + +### 3.1 Slot and value + +One reliable-broadcast instance exists for each slot: + +```text +Slot = (protocol_instance, committee_id, author, round) +``` + +The value proposed in a slot is a canonical Starfish header. Its identifier remains: + +```text +BlockReference = (author, round, content_digest) +``` + +`content_digest` commits unambiguously to the canonical header content, including the transaction +commitment, parent references, and logical acknowledgments. It does not commit to the initial +authentication sidecar, protocol instance, committee ID, reliable-broadcast messages, transaction +payload, or the transport representation of compressed acknowledgments. + +The legacy shared `BlockDigest` implementation hashes the parent-reference vector immediately +followed by the acknowledgment vector without encoding their lengths. Moving a reference across +that boundary can therefore preserve the digest without finding a BLAKE3 collision. Starfish-RBC +does not change that legacy encoding. It uses plain BLAKE3 over this canonical tagged byte stream: + +```text +0x01 || author:u16_be +0x02 || round:u32_be +0x03 || parent_count:u32_be || parent[0] || ... || parent[n-1] +0x04 || logical_ack_count:u32_be || ack[0] || ... || ack[m-1] +0x05 || creation_time_ns:u64_be +0x06 || transactions_commitment:[u8;32] + +reference = authority:u16_be || round:u32_be || digest:[u8;32] +``` + +There is deliberately no `starfish:block-ref:v2` string, protocol name, session identifier, or +authentication mode in this input: a block reference remains exactly `H(content)`. The field tags +and fixed-width counts make the content grammar unambiguous. Any future content field requires a +new canonical encoding rather than silently extending this one. + +Acknowledgments remain compressed on the wire so that RBC does not inflate the Starfish bandwidth +baseline. Before hashing, they are expanded into their logical ordered vector. The stored +compression must be canonical: the intersection is in range, denotes the maximal shared parent +suffix, and recompressing the logical vector must reproduce the stored fields byte-for-byte. An +intersection up to 255 uses the compact form; a larger index uses the legacy full-vector fallback. +Local construction follows existing Starfish semantics: a shared parent suffix is normalized to +the front of the logical vector and non-shared acknowledgments retain their relative order. Exact +duplicate parents and acknowledgments are rejected before this normalization. + +`protocol_instance` and `committee_id` are authenticated domain-separation inputs but are not added +to `BlockReference`: + +- `protocol_instance` is a nonzero 32-byte execution/session identifier shared through genesis + configuration. A fixed version string is not sufficient because benchmark keys are deterministic + and reused across independent runs. +- `committee_id` is a 32-byte BLAKE3 derive-key hash under context + `STARFISH_RBC_V1_COMMITTEE_ID`. Its canonical input contains the committee length, effective + validity and quorum thresholds, Reed-Solomon information length, the three stored optimistic + thresholds, and each authority in index order with its index, stake, and Ed25519, BLS, + ML-DSA-44, and ML-DSA-65 public keys. It excludes addresses, timeouts, private keys, and pairwise + MAC keys. + +The kernel rejects an all-zero protocol instance both at construction and deserialization, a +keyring-length mismatch, committees above `MAX_COMMITTEE_SIZE`, and deserialized committees whose +effective validity/quorum thresholds or information length disagree with the canonical formulas. + +Header processing has three distinct gates: + +1. **Structural content validation** is deterministic and view-independent. It checks the canonical + digest, intrinsic field relationships, committee membership, the previous-round quorum clock, + the committed transaction root, and protocol syntax without requiring the local initial proof, + transaction data, a shard, or locally available dependencies. The resulting pin caches the + already-validated committee ID; it does not rehash the committee for every header. +2. **Ingress admission** applies the local future-round ceiling and the monotonic new-slot floor + before candidate allocation. These local resource checks are not part of content identity. +3. **Initial authentication** determines only whether this validator may send ECHO for the + candidate. +4. **DAG admission and clean activation** resolve dependencies and determine whether the delivered + header can influence Starfish. + +The implementation must not keep these gates bundled in the current all-or-nothing block verifier. + +### 3.2 Initial header authentication + +The configured block-authentication method applies only to the author's initial header proof. Its +stable authentication code is Ed25519 `0x00`, ML-DSA-44 `0x01`, ML-DSA-65 `0x02`, or MAC `0x03`. + +Ed25519 and ML-DSA sign the BLAKE3 digest of the 119-byte base statement below. In MAC mode, author +`A` creates one receiver-specific tag for each validator `Q` over the 123-byte MAC statement: + +```text +base_statement = + "STARFISH_RBC_V1" // 15 bytes + || kind // INITIAL = 0x00 + || initial_authentication // 1 byte + || protocol_instance // 32 bytes + || committee_id // 32 bytes + || author // u16, big-endian + || round // u32, big-endian + || content_digest // 32 bytes + +InitialSignatureDigest = BLAKE3(base_statement) +InitialTag(A, Q) = MAC[k(A,Q)](base_statement || A:u16_be || Q:u16_be) +``` + +The ordered collection of these tags is the conceptual MAC vector. With direct dissemination, +each recipient receives only its own entry. The full vector is not transmitted to every peer. + +The local author does not need to authenticate its header to itself. Local construction is the +author's ECHO eligibility evidence; only messages sent to other validators need receiver-specific +initial tags. + +The atomic local-start operation returns an unforgeable handle only after the selected header is +pinned and local ECHO is recorded. Author-side signature-digest and MAC-tag generation require a +borrow of that handle and recheck the selected stored reference. The service generates all INIT +proofs while borrowing the handle, then consumes it to obtain the pinned header and queued phase +effects; arbitrary caller-supplied references cannot reach the author-side proof API. + +A valid initial proof permits an honest recipient to ECHO the header. It does not by itself make +the header clean, globally available, or safe to commit. + +### 3.3 Phase-message authentication + +ECHO and READY use pairwise MAC authentication for every initial header-authentication variant. +Their kind codes are ECHO `0x01` and READY `0x02`. For phase sender `S` and recipient `Q`: + +```text +PhaseTag(S, Q, phase, block_ref) = + MAC[k(S,Q)]( + base_statement(phase, block_ref) + || S:u16_be + || Q:u16_be + ) +``` + +The resulting phase statement is exactly 123 bytes. The selected initial-authentication mode is +bound even though every mode uses phase MACs; nodes with inconsistent modes cannot combine +transcripts. Enum discriminants, bincode, YAML, and ambiguous string concatenation are never used +as authenticated bytes. + +An inbound phase message counts only if all of the following hold: + +- its recipient is the local validator; +- its sender is a known committee member; +- its claimed sender equals the peer on the direct connection; +- its pairwise MAC verifies for that sender and recipient; +- its slot fields agree with its block reference; and +- it belongs to the active protocol run and retained slot window. + +A forwarded or replayed phase message received from another peer never counts, even if its bytes +contain a valid tag addressed to the receiver. Local ECHO/READY actions are counted locally and do +not require a loopback network message. + +The kernel enforces committee membership, rejects genesis slots, and rejects a header or phase +reference more than 100 rounds ahead of its monotonic local round before allocating candidate +state. It also exposes a separate monotonic floor for allocating previously unseen slots. That +floor defaults to round one and must not be derived automatically from DAG progress: the +integration layer may advance it only from a proved safe recovery/retirement watermark. Existing +slots remain active below the floor so late evidence for a newly observed candidate can still +complete reliable-delivery totality. The kernel rejects a floor above its current local round. + +## 4. Messages + +Version one uses the following logical messages: + +```text +HeaderProposal { + slot, + canonical_header, + initial_authentication, + transaction_data?, +} + +RbcPhaseMessage { + block_ref, + sender, + recipient, + phase, + phase_tag, +} + +HeaderRequest { + slot, + block_ref, +} + +HeaderResponse { + slot, + canonical_header, +} + +``` + +The protocol instance, committee ID, and authentication mode are fixed service context and need not +be repeated on the wire, but every tag authenticates them. ECHO and READY contain a block reference, +not the header. This avoids rebroadcasting each header quadratically. Header request/response +traffic transports data only and is never counted as quorum testimony. + +The milestone-two phase message has a golden bincode regression vector. Integrated +`NetworkMessage` variants are append-only so they do not silently change existing bincode +discriminants. + +An honest ECHO or READY sender must possess the matching content-validated header. Consequently, +when a threshold is observed before the local header arrives, the receiver can request the header +from recorded direct ECHO or READY senders. A response is accepted only after recomputing the +content digest and validating the header's structure. + +`CanonicalHeader` excludes the initial-authentication sidecar. A `HeaderResponse` therefore neither +needs nor confers a valid local initial proof. It supplies the content bytes needed for a transition +that is authorized by the receiver's own directly authenticated RBC evidence. + +The RBC header-retrieval path never asserts transaction-payload availability. Direct INIT may +co-carry transaction data in the same envelope as the one canonical header; the receiver checks it +against the transaction commitment in the pinned header, derives its local shard, and attaches or +buffers it through the existing Starfish transaction-data path. The payload is not part of the RBC +authentication sidecar or header identity. Shard relay and reconstruction remain unchanged. +Explicit missing-parent synchronization may still return a header as a recovery fallback, but +proactive full-block/header batches are disabled for Starfish-RBC. + +## 5. Local state + +State is keyed by slot, with evidence separated by candidate block reference: + +```text +SlotState { + echoed: Option, + readied: Option, + delivered: Option, + candidates: Map, +} + +CandidateState { + header: Option, + echo_senders: AuthoritySet, + ready_senders: AuthoritySet, + echo_quorum_observed: bool, + ready_validity_observed: bool, + ready_quorum_observed: bool, + dirty_admitted: bool, + rbc_delivered: bool, + clean: bool, +} +``` + +The kernel stores an `Arc`-backed `PinnedRbcHeader`, not a presence boolean. Its constructor checks +the canonical digest, acknowledgment representation, intrinsic rounds, committee membership, +previous-round quorum clock, required transaction commitment, forbidden protocol extensions, and +resource bounds. Every pin records the committee ID under which those checks ran; another committee +cannot consume it. The pin is held for as long as this validator may advertise itself as a header +holder, so cache eviction cannot silently invalidate the RBC availability invariant. + +A distinct `EchoEligibleHeader` capability is bound to the full RBC context and local recipient. It +is created only by valid direct-author initial authentication or the local-author construction path. +Recovered content can be pinned and can unblock READY/delivery, but it cannot be converted into +ECHO eligibility. Direct INIT ingress stages content even when the receiver-specific proof is +invalid and returns all effects unblocked by staging; this makes poisoned-tag handling atomic rather +than an adapter convention. A proposal received from a peer other than its claimed author is +rejected by this direct-INIT path. RBC delivery carries the pinned header rather than only its +reference. + +The `echoed`, `readied`, and `delivered` guards are slot-global, not per digest. Evidence is kept per +digest so that Byzantine equivocation can be observed without locking the receiver to the first +value it sees. + +In particular: + +- an honest validator sends at most one ECHO in a slot; +- an honest validator sends at most one READY in a slot; +- the READY value need not equal the value previously ECHOed, because a later quorum may select a + different value; and +- an honest validator delivers at most one value in a slot. + +For correctness in version one, slot-global locks, phase evidence, threshold latches, and headers +advertised by an honest local phase action are retained for the whole benchmark run. Unsupported +candidate bodies may be evicted because they can be fetched again by digest, but eviction must not +discard evidence or a pending transition. The kernel's new-slot floor is not advanced in the first +benchmark prototype. A proved nontrivial retirement boundary is deferred with crash/restart +support; arbitrary cache eviction is not a protocol action. + +Each direct sender may sponsor only its first ECHO value and first READY value in a slot. Exact +retransmissions are idempotent; later phase equivocations from that sender are verified but ignored +before candidate allocation. ECHO and READY use separate ledgers because an honest validator may +ECHO one value and later READY another. Together with one content-only direct-author candidate, +this bounds retained candidates per slot to at most `2 * committee_size - 1`: one initial candidate +plus two first-phase values from each of the other validators. A recovered header can attach only to +an already retained candidate and cannot allocate one by itself. + +Version one accepts at most 65,535 parents or logical acknowledgments per field and at most 4 MiB +of canonical header content. Bounded vector deserialization enforces the serialized parent and +extra-acknowledgment limits incrementally, before an attacker can force an unbounded vector +allocation. + +Sending a local phase is one atomic state transition: set the slot-global guard, insert the local +authority into that candidate's phase-sender set, and enqueue separately authenticated messages for +all other validators. Threshold checks include this local evidence. Message materialization checks +the recorded slot-global guard and the kernel's header-present predicate again; it cannot +authenticate a phase for an unauthorized or conflicting value. That predicate is a pinned-header +handle. The same authorized phase may be materialized again for retransmission. + +## 6. State machine + +### 6.1 Receiving an initial header + +On `HeaderProposal` for candidate `R`: + +1. Validate committee membership, slot consistency, header syntax, canonical content digest, + protocol-specific fields, and resource bounds. +2. Verify that version one's direct-INIT peer is the claimed author. A header from another peer does + not enter the content-only INIT allowance; it may be accepted later only as recovery for an + already phase-evidenced candidate. +3. Store the fixed-size-checked, content-valid direct-author header as a candidate even if its local + initial authenticator is missing or invalid. Return any READY/delivery effects unblocked by that + staging together with the authentication failure. This permits later RBC delivery to repair a + poisoned recipient tag without relying on adapter call ordering. For the local author's own + header, one mutable kernel operation fixes the author and digest, selects and pins the candidate, + records local ECHO, and only then exposes a context-bound handle for proof generation and + dissemination. This prevents two conflicting local proposals from escaping before the slot lock + is installed. +4. If the direct-author check and proof are valid and the slot-global ECHO guard is empty, record the + local ECHO immediately and send a recipient-specific ECHO for `R` to every other validator. + Header RBC does not wait for parents, acknowledgments, transaction data, or a shard to arrive. +5. Independently, connect the candidate to the dirty DAG through the normal dependency manager once + its dirty dependencies are present. This follows Sailfish's dirty/clean setup and does not make + the candidate consensus-visible. + +A relayed header may be retained through the recovery path for an already phase-evidenced +candidate, but it does not trigger ECHO in version one. Tree dissemination later changes this +eligibility rule to accept a relayed receiver-specific author proof and must extend the integrity +argument accordingly. + +The first content-valid INIT received directly from the claimed author occupies the one +content-only INIT allowance even if its proof is invalid. A later proof for the same reference may +still authenticate and ECHO it; a conflicting author proposal cannot consume more memory or replace +it. This is deliberate: a Byzantine author is not promised validity for its slot, while a different +value can still complete through first-phase evidence and header recovery. + +### 6.2 Receiving ECHO + +On a valid direct `Echo(R, S)`: + +1. If this is `S`'s first ECHO value in the slot, record `S` once in `R.echo_senders`. An exact + retransmission is idempotent. A later conflicting ECHO from `S` is ignored before allocating or + changing candidate evidence. +2. When ECHO stake for `R` reaches `Q`, latch `echo_quorum_observed` and: + - if the header is absent, request it from multiple recorded ECHO senders; + - validate and store the returned header; and + - once the header is present, send READY for `R` if the slot-global READY guard is empty. + +An ECHO quorum never bypasses content validation. + +Each honest validator counts its own locally recorded ECHO, including when that validator is the +slot author. It also sends ECHO to every other validator. The implementation must not inherit +Sailfish++'s current exclusion of the block author from ECHO stake: at `n = 3f + 1`, excluding an +honest author's ECHO leaves only `2f` honest non-author ECHOs and lets `f` Byzantine validators stop +honest-author progress by withholding theirs. + +### 6.3 Receiving READY + +On a valid direct `Ready(R, S)`: + +1. If this is `S`'s first READY value in the slot, record `S` once in `R.ready_senders`. Treat an + exact retransmission as idempotent and ignore a later conflicting READY before allocation. +2. When READY stake for `R` reaches `V`, latch `ready_validity_observed` and: + - if the header is absent, request it from multiple recorded READY senders; + - validate and store the returned header; and + - once the header is present, send READY for `R` if the slot-global READY guard is empty. +3. When READY stake for `R` reaches `Q`, latch `ready_quorum_observed`. Once the validated header is + present, locally deliver `R` if the slot-global delivery guard is empty. Emit a local + RBC-delivery event; do not construct a purportedly transferable certificate from the observed + pairwise MACs. + +READY amplification and delivery are independent of whether the local initial header proof was +valid. This is the mechanism that repairs selective or poisoned author-to-recipient MAC entries. + +### 6.4 Header retrieval + +Every honest ECHO and READY sender is a header holder. Retrieval therefore proceeds as follows: + +1. Select multiple direct phase senders as candidate sources. +2. Request the exact `BlockReference`. +3. Accept the first response whose recomputed digest and structural validation match. +4. Re-evaluate all latched ECHO/READY triggers immediately after storing the header. +5. Continue requesting while threshold progress is blocked and untried holders remain. + +`NeedHeader` is a recovery wake-up, not a one-shot delivery assumption. The kernel re-emits it when +the authenticated holder set grows and exposes the current effect to a durable retry timer. The +central integration actor keeps that timer active, initially fans out to two connected holders, +cycles through further holders every 250 ms after loss or an unresponsive response, and cancels the +request only after a matching content-validated header is pinned. The actor and its fetch state +outlive individual connection workers. Reconnection replays recipient-specific local INITs and +rematerializes locally authorized phase messages under the new connection. + +At least one honest holder exists in every `V`-stake READY set. At least `f + 1` honest holders exist +in an equal-stake `2f + 1` ECHO quorum. Byzantine responses can delay retrieval but cannot change +the accepted content. An honest validator that sends ECHO or READY retains the canonical header +until the instance's state is safely retired; in version one, that means the end of the run. + +## 7. Dirty and clean DAG integration + +Starfish-RBC follows the dual dirty/clean organization already used by Sailfish++ and other +certified variants. + +The lifecycle predicates are distinct: + +```text +candidate = canonical header is content-valid +dirty = candidate is DAG-admitted but not yet clean +delivered = local READY evidence reached Q for the candidate +clean = delivered candidate has clean referenced dependencies +``` + +Transaction-data availability is a separate predicate throughout this lifecycle. + +### 7.1 Dirty state + +A locally authenticated, dependency-connected header may enter the dirty DAG before RBC delivery. +Dirty state may support: + +- candidate retention; +- parent fetching; +- header and shard synchronization; and +- RBC progress. + +Dirty state must not influence proposal eligibility, parent selection, Starfish votes, +acknowledgments, leader decisions, ordering, or commitment. + +### 7.2 Clean activation + +A header becomes clean only when: + +- the local RBC instance delivered that exact `BlockReference`; +- the header is present and content-valid; +- every direct parent is present and clean; and +- every referenced block whose acknowledgment could influence Starfish sequencing is present and + clean. + +RBC delivery and clean activation require neither transaction data nor a local shard. Here, clean +means that the canonical header is reliably delivered and dependency-closed. A Starfish +acknowledgment requires both `clean(header)` and `data_available(header)`. + +RBC delivery may therefore precede clean activation. A Byzantine delivered header with a dangling +dependency remains outside the clean DAG and cannot block progress by honest clean vertices. + +Local RBC delivery is an alternative admission authority to the author's initial proof. If the +local initial tag was invalid or missing, the delivered header is inserted through an +RBC-authorized path and may become clean after its dependencies do. No forwarded evidence bundle +can exercise this path; it requires the local slot state to have reached delivery. + +The generic dual-DAG rule that infers cleanliness from `f + 1` references in later rounds is +disabled for `starfish-rbc`. Only local RBC delivery can supply the certification predicate; +descendant references are neither a replacement certificate nor transferable evidence of the +direct phase messages observed elsewhere. + +### 7.3 Starfish-specific clean-only rules + +For `starfish-rbc`: + +- honest proposals select only clean parents; +- proposal-round advancement requires a clean quorum in the preceding round; +- a transaction-data acknowledgment is queued only after its target is both data-available and + clean; +- clean activation rechecks acknowledgment eligibility when transaction data arrived first; +- voting and certifying blocks counted by the Starfish committer must be clean; +- committed leaders must be clean; +- the linearizer follows only clean parent and acknowledgment references; and +- a block cannot be committed merely because it exists in the dirty DAG. + +The existing generic dual-DAG helpers are useful but do not by themselves enforce all of these +Starfish acknowledgment and linearizer rules. Each consumer must be audited explicitly. + +## 8. Initial dissemination + +Version one uses direct author-to-all dissemination: + +- Ed25519 and ML-DSA send the same public signature to every recipient. +- MAC mode sends each recipient only its own 32-byte initial tag. +- All modes then run exactly the same pairwise-MAC ECHO/READY protocol. + +Every ECHO and READY broadcast is materialized separately for each recipient because its phase tag +binds that recipient. A broadcaster must never clone one serialized, tagged phase message to all +peers. The sender records its own phase action locally instead of creating a loopback tag. + +This direct strategy is the correctness baseline. It deliberately separates reliable-delivery +correctness from routing failures. + +A later tree-dissemination milestone may send a relay the tags for its assigned subtree and let it +forward smaller sub-bundles. That optimization must include redundant paths or a timeout fallback; +a single tree containing a Byzantine relay is not live. Tree dissemination must not alter the RBC +state machine or its clean-DAG rules. + +Under the motivating paper's broad definition, an author-to-recipient MAC that remains verifiable +after a third party relays it is transferable authentication for that intended recipient, although +it is not a publicly verifiable signature. The future tree variant therefore lies outside the +paper's no-transferable-authentication lower bound and needs its own integrity argument. + +## 9. Safety argument to complete + +The implementation and accompanying proof must establish at least the following lemmas. + +### 9.1 Honest-author integrity + +If an honest validator ECHOs a header attributed to an honest author, the author created the +matching initial proof. An adversary cannot produce an honest recipient's valid initial MAC or a +valid public signature for a different header. + +The recipient of a pairwise MAC also knows its key and could fabricate a tag addressed to itself. +An honest recipient follows the protocol and never does so. Byzantine recipients control less than +one-third stake and therefore cannot create an ECHO quorum for a value that no honest recipient +authenticated. The later relayed-INIT variant must state this argument explicitly rather than +claiming public non-repudiation from a MAC. + +### 9.2 Unique ECHO quorum + +Two conflicting values cannot both obtain more than two-thirds ECHO stake. Their ECHO sets +intersect in more than one-third stake, which contains an honest validator; the slot-global ECHO +guard prevents that validator from ECHOing both. + +### 9.3 Unique READY value + +The first honest READY for a value is rooted in an ECHO quorum. Subsequent honest READY messages +are rooted either in that quorum or in more than one-third READY stake, which contains an honest +READY sender. Therefore honest READY propagation cannot originate independently for two values. + +### 9.4 Reliable-delivery agreement and totality + +If one honest validator delivers `R`, more than one-third honest stake sent READY for `R`. Reliable +direct delivery of those READY messages causes every honest validator to amplify READY and +eventually observe a quorum. Header-holder retrieval gives every honest validator the exact bytes +needed to deliver `R`. + +### 9.5 Starfish composition + +Only locally delivered, dependency-closed clean headers influence Starfish. Reliable delivery +provides a consistent value for each author/round slot and prevents false attribution to an honest +author. The Starfish safety argument must then be checked over the clean DAG, including its +acknowledgment-based linearizer. + +These lemmas are proof obligations. They are not established merely by reusing Sailfish++ code. + +## 10. Liveness argument to complete + +For an honest author after GST: + +1. Direct dissemination eventually gives every honest validator the header and a valid initial + proof. +2. Honest parent selection ensures the referenced parent closure is eventually clean everywhere. +3. All honest validators ECHO the same reference. +4. Every honest validator observes ECHO quorum, sends READY, observes READY quorum, and delivers. +5. The header becomes clean once its already-clean dependencies are locally present. +6. At least quorum honest stake can therefore produce clean vertices in every live round, allowing + plain Starfish to advance and commit. + +The proof must also show that dirty Byzantine candidates, missing initial tags, invalid phase MACs, +and dangling Byzantine dependencies cannot influence clean Starfish state or prevent an honestly +scheduled quorum from progressing. Fair processing despite Byzantine traffic is assumed; +resource-exhaustion resistance is outside the version-one model. + +## 11. Required adversarial tests + +Implementation begins with deterministic tests for the failure modes, not only happy-path smoke +tests. + +### 11.1 RBC unit tests + +The milestone-two suite includes deterministic four-kernel message-pump traces for split initial +values and poisoned-recipient MAC recovery, in addition to the local transition tests below. + +- `n = 4, f = 1`: a Byzantine author gives candidate X to one honest validator and candidate Y to + two others. A validator that first saw X must still process quorum traffic for Y. +- A Byzantine author provides valid initial tags to enough validators to form an ECHO quorum but + gives D an invalid or missing tag; D does not ECHO but eventually retrieves and delivers the same + header through READY evidence. +- A Byzantine author distributes valid receiver tags for conflicting headers; at most one value is + delivered by honest validators. +- One sender's duplicate ECHO or READY counts once. +- The slot author's locally recorded ECHO and directly received ECHOs count toward the ECHO quorum; + excluding the author makes the `n = 3f + 1` honest-sender case non-live when all Byzantine + validators withhold. +- Byzantine senders may equivocate across values without making an honest sender violate its + slot-global guards. +- Invalid, wrong-recipient, wrong-phase, stale, and wrong-slot phase MACs are rejected. +- A valid phase message replayed through a different peer is not counted as direct testimony. +- ECHO or READY quorum without local header triggers retrieval and does not advance until a valid + matching header is present. +- A threshold reached before header retrieval remains latched and fires immediately after the + matching header is stored. +- A tampered `HeaderResponse` is rejected by content-digest validation. + +### 11.2 DAG integration tests + +- Locally authenticated but undelivered headers remain dirty. +- RBC-delivered headers with an unclean parent remain outside the clean DAG. +- Cleaning the parent activates the already-delivered child. +- Dirty headers cannot advance the proposal round or become proposal parents. +- Later-round references cannot infer that an RBC-undelivered header is clean. +- Data availability for an unclean target does not queue a Starfish acknowledgment. +- Starfish voting, leader selection, and linearization ignore dirty blocks. +- No honest node commits two values for one `(author, round)` slot. + +### 11.3 End-to-end tests + +- Four honest validators commit using Ed25519, ML-DSA-44, ML-DSA-65, and MAC initial + authentication with identical RBC message flow. +- A poisoned-recipient-tag run still lets every honest validator clean and commit the same header. +- Byzantine equivocation does not split committed histories. +- A Byzantine dangling-parent block does not prevent quorum honest progress. +- Header recovery succeeds when quorum evidence arrives before header data. +- Existing protocols retain their current behavior and test results. + +Crash/restart tests are intentionally deferred until sent-phase and delivered-slot state is made +durable. + +## 12. Benchmark plan + +The fair comparison holds Starfish-RBC ordering, reliable broadcast, direct dissemination, load, +committee, topology, and timeout configuration constant. Only initial header authentication varies: + +- `starfish-rbc` + Ed25519; +- `starfish-rbc` + ML-DSA-44; +- `starfish-rbc` + ML-DSA-65; and +- `starfish-rbc` + MAC. + +Native Starfish with Ed25519 and ML-DSA should also be measured separately. That comparison shows +the total cost of reliable delivery, but it must not be presented as an isolated authentication +comparison because the message flows differ. + +The existing unsafe `starfish-mac` lower bound sends the full committee-sized MAC vector with each +direct-author streamed header, while Starfish-RBC/MAC sends only the recipient's tag and adds +ECHO/READY traffic. Their measured delta is therefore the net whole-protocol cost, not a pure RBC +overhead number: saved INIT bytes can hide part of the phase-message cost. The benchmark must report +INIT/header bytes and ECHO/READY bytes separately; a one-tag lower-bound projection may be added but +must be labeled as such. + +Starfish-RBC sends the canonical header once through INIT (or explicit header recovery), with +optional direct transaction data in the same INIT envelope. This removes the steady-state duplicate +header that the first integrated prototype carried inside an ordinary full-block batch without +opening a second scheduling gap before the payload. The per-message framed-byte counters report +the combined INIT bytes and ECHO/READY costs separately. + +Metrics should separate: + +- initial header-authentication bytes and CPU; +- ECHO/READY phase-MAC bytes and CPU; +- header proposal, phase, and recovery traffic; +- time from dirty admission to RBC delivery and clean activation; +- block and transaction commit latency; +- throughput and per-node inbound/outbound bandwidth; and +- author egress versus aggregate network traffic. + +MAC vectors and public signatures both require each non-author to receive the header in an +all-honest direct run. The expected MAC benefit is proof size, computation, and later author/tree +egress relative to large post-quantum signatures, not a reduction in the number of required +recipients. + +Benchmark results should live in experiment artifacts or a concise PR graph/summary, not as +long-lived tables in the protocol documentation. + +## 13. Contained implementation boundary + +The implementation should add one `StarfishRbc` capability path and one isolated RBC service. It +may reuse networking, stake aggregation, dirty/clean storage, and event plumbing, but must not copy +the following Sailfish++ or legacy MAC behavior: + +- per-digest send/delivery guards or first-seen canonical locking; +- exclusion of the block author's ECHO, optimistic `VOTE`, or Sailfish timeout semantics; +- unsigned phase messages without recipient, session context, and a phase MAC; +- cloning one identical phase message to every recipient; +- full-vector direct-author transport when one recipient tag is sufficient; +- an all-or-nothing verifier that combines canonical content with initial authentication; or +- inferred-clean promotion and pre-clean acknowledgment queuing. + +Plain Starfish, Starfish-Speed, Sparse-Starfish-Speed, Bluestreak, Sailfish++, and their existing +authentication selections remain behaviorally unchanged. The first prototype does not create an +RBC copy of every protocol. + +## 14. Implementation milestones + +Each milestone is committed separately. + +1. **Specification (complete):** this document, initially with no protocol code changes. +2. **RBC kernel (complete):** domain-separated initial/phase statements, explicit session and + committee identity, slot-global ECHO/READY state, guarded recipient-specific message + materialization, retryable header-holder tracking, and local plus four-kernel adversarial tests. + The synchronous kernel is intentionally not network- or DAG-wired yet. +3. **Canonical header boundary (complete):** split content validation from initial authentication, + add the tagged length-delimited content digest, canonical compressed acknowledgments, + committee-bound pinned headers, context-bound ECHO capabilities, atomic poisoned-proof staging, + bounded phase equivocation, round admission seams, pending triggers, and holder-backed + multi-kernel recovery tests. The durable network fetch owner is part of milestone four. +4. **Certified Starfish integration (complete):** add `starfish-rbc`, selectable initial + authentication, the + network RBC service and durable multi-holder fetch retry, dirty/clean lifecycle, clean-only + acknowledgments, clean-only consensus/linearization, per-peer ordered outbound isolation, + single-header INIT transaction transport, and fresh per-run protocol-instance distribution. +5. **End-to-end validation (in progress):** all four initial-authentication modes commit in a + four-validator network test, and a late-joining MAC validator catches up. Kernel-level + poisoned-tag and equivocation tests are complete; composed dangling-parent and Byzantine + network tests remain. +6. **Tree dissemination:** subtree tag bundles, redundant routing/fallback, and matching signature + baselines. +7. **Recovery:** durable phase locks and delivered state, evidence replay, late-node synchronization, + and restart tests. +8. **Benchmarks:** direct and tree comparisons with results reported outside this specification. + +## 15. Remaining integration and research decisions + +The kernel behavior, content digest, authenticated encoding, size limits, and admission semantics +above are fixed. The orchestrator generates one fresh nonzero protocol instance before serializing +the shared node configuration; version one uses two-holder recovery fanout with a 250 ms retry; and +the legacy unsafe aliases remain explicitly labeled lower bounds. Remaining work must choose: + +- a safe post-v1 state-retirement and garbage-collection rule; +- a catch-up mechanism for an honest validator delayed by more than the current 100-round admission + window without reopening unbounded Byzantine slot allocation; +- a bounded per-peer RBC outbound queue policy that preserves honest-peer isolation under a stalled + receiver; +- production-authenticated connection identity and durable phase/delivery recovery. + +The authentication selector remains `--block-authentication`; Starfish-RBC integration adds `mac` +to the existing Ed25519, ML-DSA-44, and ML-DSA-65 values while retaining Ed25519 as the default. + +None of these choices may weaken the slot-global phase guards, direct-message checks, header +availability requirement, or clean-only Starfish boundary. diff --git a/docs/starfish-rbc-single-dag-v3.md b/docs/starfish-rbc-single-dag-v3.md new file mode 100644 index 00000000..38fa0b8c --- /dev/null +++ b/docs/starfish-rbc-single-dag-v3.md @@ -0,0 +1,141 @@ +# Single-DAG Starfish-RBC V3 + +## Status + +This is the active experimental one-block design built on the direct-header +Starfish-RBC implementation. It is selected as `starfish-rbc-single-dag`. + +V3 is a research-testbed protocol. Crash recovery, bounded retirement and the +complete asynchronous safety/liveness proof remain required before production +use. + +## One identity and one DAG + +Every protocol vertex is an ordinary Starfish `VerifiedBlock`. Its canonical +`BlockReference` simultaneously identifies: + +1. the author's reliable-broadcast proposal; +2. the dirty/authenticated DAG vertex; +3. the delivered/clean DAG vertex; and +4. the vertex considered by the existing Starfish committer. + +V3 has no `CandidateCarrierV1`, nested `ConsensusVertexV1`, carrier clock, +delivery frontier, or physical-to-logical projection. Dirty and clean are +monotone states of the same vertex, not separate DAG identities. + +## Typed RBC references + +The V3 block header contains a canonical `StarfishRbcFieldsV3` list. Each item +is one of: + +- `Echo(BlockReference)`; or +- `Ready(BlockReference)`. + +The carrying block's authenticated author is the statement sender. The list is +part of that ordinary block's content digest. Consequently no standalone phase +MAC, signature, or phase network message is needed. + +References are sorted, duplicate-free and bounded by `6 * committee_size` per +block. A sender may name at most one digest for each `(phase, target author, +target round)`. Targets must be non-genesis, known-authority references no newer +than the carrying block. + +## Progress and cleanliness + +Block production follows the authenticated dirty threshold clock: a V3 block +may causally reference authenticated previous-round blocks that have not yet +been RBC-delivered. This is necessary to prevent a circular wait in which RBC +evidence needs a later block while later blocks require earlier RBC delivery. + +The existing clean DAG remains fail-closed. A block becomes consensus-visible +only after: + +1. its own RBC instance delivers the exact canonical header; +2. its payload is data-available where required; and +3. every causal parent and ordering acknowledgment is clean. + +Typed RBC evidence references are testimony, not causal parents. Missing or +selectively supplied Byzantine evidence therefore cannot contaminate the +carrying block's clean dependency cone. + +## Communication + +Normal communication consists only of ordinary Starfish block proposals. RBC +ECHO/READY statements ride in later blocks. Header, payload and missing-parent +requests are synchronization/recovery traffic and remain permitted. A V3 node +must neither emit nor count the legacy standalone `RbcPhase` messages. + +## Initial pipeline + +After authenticating a proposal, a validator locks an ECHO and queues its typed +reference for the next ordinary block. Observing quorum ECHO references locks a +READY reference for a later ordinary block. Quorum READY references deliver the +original block. These waves pipeline across normal Starfish rounds; they do not +create a second physical round counter. + +## Safety boundaries + +- Block authentication and the V3 digest bind every embedded statement to its + sender. +- Sender locks prohibit conflicting ECHO or READY references for one target + slot. +- RBC delivery never follows from dirty-DAG admission alone. +- Consensus parent selection and commitment use only clean vertices. +- Recovery content must recompute to the exact requested `BlockReference`. +- Direct-RBC formats retain their existing domains. + +### Receiver-local quorum-ECHO latency lower bound + +Finite benchmarks may opt into +`--starfish-rbc-single-dag-echo-qc-fast-path`. In that mode a node delivers an +exact header as soon as it has quorum locked ECHO statements, while still +emitting the normal READY statement. There is no portable QC object, witness +vector, public signature, standalone phase message, or additional fast-path +communication: each receiver acts only on ordinary DAG blocks that it +authenticated directly with its recipient-specific MAC. + +Quorum intersection and the per-sender slot locks prevent two conflicting +headers from both obtaining honest receiver-local quorums, so the experiment +retains integrity and agreement/uniqueness. It does **not** provide normal RBC +totality: Byzantine ECHO senders can selectively reveal their statements so +one honest node obtains quorum and delivers while another honest node never +can. Relaying header bytes does not transfer pairwise-MAC testimony. The flag +is therefore a finite-testbed latency lower bound, is rejected for unbounded +or production runs, and must not be described as a Byzantine-totality-safe +RBC. The default V3 path continues to require quorum READY. + +Two attempted ways to make this fast-path evidence portable were removed. A +public aggregate-signature certificate violated the signature-free design +constraint. A complete per-recipient MAC vector plus exact witness references +remained signature-free, but made proposals and certificates grow with the +committee and overloaded the n=40 single-machine testbed. Neither proof format +is part of the current V3 wire protocol. + +## Required validation + +Before using V3 benchmark results, tests must cover canonical identity, +conflicting sender locks, dirty-clock progress, clean dependency closure, +embedded ECHO/READY delivery, absence of normal phase messages, Byzantine +withholding with bounded recovery, restart replay and deterministic Starfish +commit order. Matched n=10 and n=40 zero/AWS runs must compare V3 against the +direct-RBC baseline using identical load and duration. + +## Testbed checkpoint + +Matched local runs used 1,000 offered transactions/s for 20 seconds with MAC +authentication and the fixed 50 ms V3 round limiter: + +| Protocol/profile | Network | p50 E2E | Eventual TPS | Outbound/node | +| --- | ---: | ---: | ---: | ---: | +| `starfish-rbc-single-dag` | zero | 498.3 ms | 1,000 | 0.79 MB/s | +| `starfish-rbc-single-dag` | AWS table | 1,285.7 ms | 1,000 | 0.61 MB/s | +| V3 + receiver-local quorum-ECHO, n=10 | zero | 400.5 ms | 1,000 | 0.79 MB/s | +| V3 + receiver-local quorum-ECHO, n=10 | AWS table | 956.0 ms | 1,000 | 0.61 MB/s | +| V3 + receiver-local quorum-ECHO, n=40 | zero | 390.2 ms | 1,000 | 5.88 MB/s | +| V3 + receiver-local quorum-ECHO, n=40 | AWS table | 968.2 ms | 1,000 | 3.07 MB/s | +| `starfish-mac` lower bound | AWS table | 610.0 ms | 1,000 | 0.61 MB/s | + +All exact offered transactions committed during the bounded drain. These are +single-machine research measurements, not production claims. In particular, +the receiver-local rows preserve integrity and agreement but carry the +Byzantine-totality limitation above. diff --git a/local-dryrun/README.md b/local-dryrun/README.md index 390d43cb..2003eccf 100644 --- a/local-dryrun/README.md +++ b/local-dryrun/README.md @@ -30,11 +30,17 @@ NUM_NODES=10 DESIRED_TPS=1000 CONSENSUS=starfish \ | `NUM_CRASHED_NODES` | `0` | Number of highest-numbered authorities to leave down from startup | | `DESIRED_TPS` | `1000` | Target transactions per second (split evenly across nodes) | | `CONSENSUS` | `bluestreak` | Consensus protocol (see below) | +| `BLOCK_AUTHENTICATION` | *(unset)* | `ed25519`, `ml-dsa-44`, or `ml-dsa-65`; unset defaults to Ed25519 | | `TEST_TIME` | `3000` | Experiment duration in seconds | -Supported `CONSENSUS` values: `starfish`, `starfish-speed`, -`sparse-starfish-speed`, `starfish-bls`, `cordial-miners`, -`mysticeti`, `sailfish-pp`, `bluestreak`, `mysticeti-bls`. +Supported `CONSENSUS` values are `starfish`, `starfish-speed`, +`sparse-starfish-speed`, `bluestreak`, `starfish-bls`, `cordial-miners`, +`mysticeti`, `sailfish-pp`, `mysticeti-bls`, `starfish-rbc`, and +`starfish-rbc-single-dag`. `BLOCK_AUTHENTICATION` selects Ed25519, ML-DSA-44, +ML-DSA-65, or `mac` (the latter only for Starfish-RBC) for any of them. The +`starfish-mac`, `starfish-speed-mac`, `sparse-starfish-speed-mac`, and +`bluestreak-mac` names are separate experimental protocols; leave +`BLOCK_AUTHENTICATION` unset when using one. ### Protocol Tuning diff --git a/local-dryrun/dryrun.sh b/local-dryrun/dryrun.sh index b4ed06a7..3ca409f0 100755 --- a/local-dryrun/dryrun.sh +++ b/local-dryrun/dryrun.sh @@ -6,10 +6,10 @@ NUM_NODES=${NUM_NODES:-10} NUM_CRASHED_NODES=${NUM_CRASHED_NODES:-0} DESIRED_TPS=${DESIRED_TPS:-100} -# Options: starfish, starfish-speed, sparse-starfish-speed, -# starfish-bls, cordial-miners, mysticeti, sailfish-pp, -# bluestreak, mysticeti-bls +# Signature schemes: ed25519 (default), ml-dsa-44, ml-dsa-65. +# The *-mac names remain separate experimental protocols. CONSENSUS=${CONSENSUS:- sparse-starfish-speed} +BLOCK_AUTHENTICATION=${BLOCK_AUTHENTICATION:-} NUM_BYZANTINE_NODES=${NUM_BYZANTINE_NODES:-0} # Options: timeout-leader, leader-withholding, # equivocating-chains, equivocating-two-chains, @@ -573,6 +573,10 @@ EOH if [ "${COMPRESS_NETWORK:-0}" = 1 ]; then PARAM_FLAGS+=" --compress-network" fi + if [ -n "$BLOCK_AUTHENTICATION" ]; then + PARAM_FLAGS+=" --block-authentication" + PARAM_FLAGS+=" $BLOCK_AUTHENTICATION" + fi cat <