diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e7c604..e4687a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,9 +99,17 @@ jobs: hivezilla/stream-relay/examples/rust-consumer/Cargo.toml; do cargo test --locked --manifest-path "$manifest" done + - name: Build the shared consensus Wasm reader + env: + RUSTFLAGS: "" + CARGO_TARGET_DIR: target/consensus-wasm + run: | + rustup target add wasm32-unknown-unknown + cargo build --locked --release --target wasm32-unknown-unknown --manifest-path hivezilla/stream-relay/decoder/Cargo.toml + cp target/consensus-wasm/wasm32-unknown-unknown/release/blockzilla_stream_decoder.wasm hivezilla/stream-relay/client/decoder-consensus-v1.wasm - name: Test JavaScript SDK and Wasm fixtures working-directory: hivezilla/stream-relay/client - run: node --test test.mjs sdk-test.mjs banks-test.mjs + run: node --test test.mjs sdk-test.mjs banks-test.mjs consensus-test.mjs - name: Test release graph inspection run: python3 scripts/test_check_crate_release.py diff --git a/crates/blockzilla-bank-stream-v2/src/consensus.rs b/crates/blockzilla-bank-stream-v2/src/consensus.rs new file mode 100644 index 0000000..923e19f --- /dev/null +++ b/crates/blockzilla-bank-stream-v2/src/consensus.rs @@ -0,0 +1,320 @@ +//! Immutable, source-bound native footer facts. This profile is independent of +//! execution Frame v2. Native bytes use the pinned Agave 4.3.2 grammar; the view +//! preserves observations, not a claim that a certificate or BLS signature was verified. +use crate::{BankIdentity, SAFE, SourceIdentity, is_digest}; +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize, Serializer}; + +pub const PROFILE: &str = "native-consensus-fact-v1"; +pub const NATIVE_REVISION: &str = "87167f3906bdf5111e99ead64d8f4098b7874e33"; +pub const OFFICIAL_REVISION: &str = "a203300045b405f86c9c84308b2eae76af69ac3b"; +pub const NATIVE_SCHEMA: &str = "agave-4.3.2-versioned-block-footer-v1"; +pub const VERIFICATION: &str = "captured_not_verified"; +pub const MAGIC: &[u8; 8] = b"BZCNF001"; +pub const VERSION: u16 = 1; +pub const MAX_BINDING_BYTES: usize = 4096; +/// Agave's enclosing LengthPrefixed block marker has a u16 byte length. +pub const MAX_FOOTER_BYTES: usize = u16::MAX as usize; +pub const HEADER_BYTES: usize = 20; +pub const MAX_FACT_BYTES: usize = HEADER_BYTES + MAX_BINDING_BYTES + MAX_FOOTER_BYTES; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FactBinding { + pub source: SourceIdentity, + pub bank: BankIdentity, + pub execution_payload_sha256: String, + pub source_event_sequence: u64, + pub source_event_sha256: String, +} +impl FactBinding { + pub fn validate(&self) -> Result<()> { + self.source.validate()?; + self.bank.validate()?; + ensure!( + (self.source.agave_revision == NATIVE_REVISION + || self.source.agave_revision == OFFICIAL_REVISION), + "unsupported_native_footer_revision" + ); + ensure!( + (1..=SAFE).contains(&self.source_event_sequence) + && is_digest(&self.execution_payload_sha256) + && is_digest(&self.source_event_sha256), + "invalid_consensus_fact_binding" + ); + Ok(()) + } +} + +/// The exact native footer occurs once in the stored fact. Its parsed view is +/// borrowed on demand. The event digest is a binding to external captured +/// evidence, not a digest of the footer alone; the exporter must check that evidence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConsensusFact { + pub binding: FactBinding, + footer_wire: Vec, +} +impl ConsensusFact { + pub fn new(binding: FactBinding, footer_wire: Vec) -> Result { + binding.validate()?; + decode_footer(&footer_wire)?; + Ok(Self { + binding, + footer_wire, + }) + } + pub fn footer_wire(&self) -> &[u8] { + &self.footer_wire + } + pub fn footer(&self) -> Result> { + decode_footer(&self.footer_wire) + } + pub fn validate_binding(&self, expected: &FactBinding) -> Result<()> { + self.binding.validate()?; + expected.validate()?; + ensure!(&self.binding == expected, "consensus_fact_binding_mismatch"); + Ok(()) + } +} + +/// Fixed little-endian header: magic8, portable version u16, native grammar +/// code u16 (=1), binding JSON length u32, native footer length u32. JSON +/// carries only the binding. The footer is exact pinned native Wincode bytes. +pub fn encode(fact: &ConsensusFact) -> Result> { + fact.binding.validate()?; + decode_footer(fact.footer_wire())?; + let binding = serde_json::to_vec(&fact.binding)?; + ensure!( + !binding.is_empty() && binding.len() <= MAX_BINDING_BYTES, + "consensus_binding_too_large" + ); + let mut output = Vec::with_capacity(HEADER_BYTES + binding.len() + fact.footer_wire.len()); + output.extend_from_slice(MAGIC); + output.extend_from_slice(&VERSION.to_le_bytes()); + output.extend_from_slice(&1_u16.to_le_bytes()); + output.extend_from_slice(&(binding.len() as u32).to_le_bytes()); + output.extend_from_slice(&(fact.footer_wire.len() as u32).to_le_bytes()); + output.extend_from_slice(&binding); + output.extend_from_slice(fact.footer_wire()); + Ok(output) +} +pub fn decode(bytes: &[u8]) -> Result { + ensure!(bytes.len() <= MAX_FACT_BYTES, "consensus_fact_too_large"); + let mut c = Cursor::new(bytes); + ensure!(c.take(8)? == MAGIC, "invalid_consensus_fact_magic"); + ensure!(c.u16()? == VERSION, "unsupported_consensus_fact_version"); + ensure!(c.u16()? == 1, "unsupported_native_footer_schema"); + let binding_len = c.u32()? as usize; + let footer_len = c.u32()? as usize; + ensure!( + (1..=MAX_BINDING_BYTES).contains(&binding_len) + && (1..=MAX_FOOTER_BYTES).contains(&footer_len), + "invalid_consensus_fact_lengths" + ); + let expected = HEADER_BYTES + .checked_add(binding_len) + .and_then(|n| n.checked_add(footer_len)) + .context("consensus_fact_length_overflow")?; + ensure!(bytes.len() == expected, "consensus_fact_length_mismatch"); + let binding: FactBinding = serde_json::from_slice(c.take(binding_len)?)?; + binding.validate()?; + let footer = c.take(footer_len)?; + decode_footer(footer)?; // Validate all lengths before copying the one raw value. + c.end()?; + ConsensusFact::new(binding, footer.to_vec()) +} +pub fn decode_bound(bytes: &[u8], expected: &FactBinding) -> Result { + let value = decode(bytes)?; + value.validate_binding(expected)?; + Ok(value) +} + +fn decimal_u64(value: &u64, serializer: S) -> std::result::Result { + serializer.serialize_str(&value.to_string()) +} +/// A Votor consensus target identity. It is deliberately a different type +/// from an execution bank hash or a recent blockhash. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct ConsensusBlockId(pub [u8; 32]); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Aggregate<'a> { + /// Exactly 96 captured compressed BLS bytes. Not cryptographically verified. + pub signature: &'a [u8], + pub bitmap: &'a [u8], +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct FinalizationCertificate<'a> { + #[serde(serialize_with = "decimal_u64")] + pub slot: u64, + /// Consensus BlockId targeted by this certificate, not its carrier's ID. + pub block_id: ConsensusBlockId, + pub final_aggregate: Aggregate<'a>, + pub notar_aggregate: Option>, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct SkipRewardCertificate<'a> { + #[serde(serialize_with = "decimal_u64")] + pub slot: u64, + pub aggregate: Aggregate<'a>, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct NotarRewardCertificate<'a> { + #[serde(serialize_with = "decimal_u64")] + pub slot: u64, + pub block_id: ConsensusBlockId, + pub aggregate: Aggregate<'a>, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct FooterView<'a> { + pub native_schema: &'static str, + pub verification: &'static str, + /// Execution bank hash from the footer; not the consensus BlockId or the + /// BankIdentity recent blockhash. No equality between those is inferred. + pub bank_hash: [u8; 32], + #[serde(serialize_with = "decimal_u64")] + pub block_producer_time_nanos: u64, + pub block_user_agent: &'a [u8], + pub block_final_cert: Option>, + /// Reward aggregates are retained separately from finalization certificates. + pub skip_reward_cert: Option>, + pub notar_reward_cert: Option>, +} + +/// Decode exactly VersionedBlockFooter::V1 as serialized by the pinned plugin. +/// No outer BlockMarker tag/length prefix, PoH inference, trial schema decode, +/// current-bank consensus ID or signature verification is introduced. +pub fn decode_footer(bytes: &[u8]) -> Result> { + ensure!( + !bytes.is_empty() && bytes.len() <= MAX_FOOTER_BYTES, + "native_footer_size_invalid" + ); + let mut c = Cursor::new(bytes); + ensure!(c.u8()? == 1, "unsupported_native_footer_version"); + let bank_hash = c.hash()?; + let block_producer_time_nanos = c.u64()?; + let user_agent_len = c.u8()? as usize; + let block_user_agent = c.take(user_agent_len)?; + let block_final_cert = if c.option()? { + let slot = c.u64()?; + let block_id = ConsensusBlockId(c.hash()?); + let final_aggregate = c.aggregate(false)?; + let notar_aggregate = if c.option()? { + Some(c.aggregate(false)?) + } else { + None + }; + Some(FinalizationCertificate { + slot, + block_id, + final_aggregate, + notar_aggregate, + }) + } else { + None + }; + let skip_reward_cert = if c.option()? { + Some(SkipRewardCertificate { + slot: c.u64()?, + aggregate: c.aggregate(true)?, + }) + } else { + None + }; + let notar_reward_cert = if c.option()? { + Some(NotarRewardCertificate { + slot: c.u64()?, + block_id: ConsensusBlockId(c.hash()?), + aggregate: c.aggregate(true)?, + }) + } else { + None + }; + c.end()?; + Ok(FooterView { + native_schema: NATIVE_SCHEMA, + verification: VERIFICATION, + bank_hash, + block_producer_time_nanos, + block_user_agent, + block_final_cert, + skip_reward_cert, + notar_reward_cert, + }) +} + +struct Cursor<'a> { + bytes: &'a [u8], + position: usize, +} +impl<'a> Cursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, position: 0 } + } + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self + .position + .checked_add(n) + .context("native_footer_length_overflow")?; + let value = self + .bytes + .get(self.position..end) + .context("truncated_consensus_fact")?; + self.position = end; + Ok(value) + } + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.take(2)?.try_into()?)) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.take(4)?.try_into()?)) + } + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.take(8)?.try_into()?)) + } + fn hash(&mut self) -> Result<[u8; 32]> { + Ok(self.take(32)?.try_into()?) + } + fn option(&mut self) -> Result { + match self.u8()? { + 0 => Ok(false), + 1 => Ok(true), + _ => anyhow::bail!("invalid_native_footer_option"), + } + } + fn short_u16(&mut self) -> Result { + let a = self.u8()?; + if a < 128 { + return Ok(a as usize); + } + let b = self.u8()?; + if b < 128 { + ensure!(b != 0, "noncanonical_short_u16"); + return Ok((a as usize & 127) | ((b as usize) << 7)); + } + let d = self.u8()?; + ensure!((1..=3).contains(&d), "invalid_short_u16"); + Ok((a as usize & 127) | ((b as usize & 127) << 7) | ((d as usize) << 14)) + } + fn aggregate(&mut self, short: bool) -> Result> { + let signature = self.take(96)?; + let len = if short { + self.short_u16()? + } else { + self.u16()? as usize + }; + let bitmap = self.take(len)?; + Ok(Aggregate { signature, bitmap }) + } + fn end(&self) -> Result<()> { + ensure!( + self.position == self.bytes.len(), + "trailing_consensus_fact_bytes" + ); + Ok(()) + } +} diff --git a/crates/blockzilla-bank-stream-v2/src/lib.rs b/crates/blockzilla-bank-stream-v2/src/lib.rs index 1c77161..a15ceb0 100644 --- a/crates/blockzilla-bank-stream-v2/src/lib.rs +++ b/crates/blockzilla-bank-stream-v2/src/lib.rs @@ -658,6 +658,7 @@ impl Notice { pub mod artifact; pub mod chain; pub mod client; +pub mod consensus; #[cfg(any(test, feature = "test-fixtures"))] pub mod fixtures; #[cfg(test)] diff --git a/crates/blockzilla-bank-stream-v2/tests/consensus.rs b/crates/blockzilla-bank-stream-v2/tests/consensus.rs new file mode 100644 index 0000000..05a57a3 --- /dev/null +++ b/crates/blockzilla-bank-stream-v2/tests/consensus.rs @@ -0,0 +1,348 @@ +use blockzilla_bank_stream_v2::{BankIdentity, SourceIdentity, consensus::*}; + +fn binding() -> FactBinding { + FactBinding { + source: SourceIdentity { + genesis_hash: "11111111111111111111111111111111".into(), + node_identity: "11111111111111111111111111111111".into(), + agave_revision: NATIVE_REVISION.into(), + session_id: "native-test-1".into(), + }, + bank: BankIdentity { + slot: 460_000_100, + bank_id: 23, + blockhash: [8; 32], + parent_slot: 460_000_099, + parent_blockhash: [7; 32], + }, + execution_payload_sha256: "ab".repeat(32), + source_event_sequence: 123, + source_event_sha256: "cd".repeat(32), + } +} +fn short(n: usize) -> Vec { + let mut value = n; + let mut out = Vec::new(); + loop { + let low = (value & 127) as u8; + value >>= 7; + out.push(low | if value > 0 { 128 } else { 0 }); + if value == 0 { + return out; + } + } +} +fn aggregate(out: &mut Vec, byte: u8, bitmap: &[u8], is_short: bool) { + out.extend_from_slice(&[byte; 96]); + out.extend(if is_short { + short(bitmap.len()) + } else { + (bitmap.len() as u16).to_le_bytes().to_vec() + }); + out.extend_from_slice(bitmap); +} +// Fixed schema fixture follows Agave87167 entry/block_component.rs240–247, +//299–328 and votor-messages/reward_certificate.rs34–42,75–85. Signatures are +//deliberate test bytes; this fixture makes no BLS or mainnet-capture claim. +fn fixture() -> Vec { + let mut bytes = vec![1]; + bytes.extend_from_slice(&[9; 32]); + bytes.extend_from_slice(&1_792_737_800_123_456_789u64.to_le_bytes()); + bytes.push(3); + bytes.extend_from_slice(b"abc"); + bytes.push(1); + bytes.extend_from_slice(&459_999_900u64.to_le_bytes()); + bytes.extend_from_slice(&[10; 32]); + aggregate(&mut bytes, 0x11, &[0x80, 0xff], false); + bytes.push(1); + aggregate(&mut bytes, 0x22, &[0x03], false); + bytes.push(1); + bytes.extend_from_slice(&459_999_899u64.to_le_bytes()); + aggregate(&mut bytes, 0x33, &[0x55; 128], true); + bytes.push(1); + bytes.extend_from_slice(&459_999_898u64.to_le_bytes()); + bytes.extend_from_slice(&[11; 32]); + aggregate(&mut bytes, 0x44, &[0x66], true); + bytes +} +fn minimal() -> Vec { + let mut bytes = vec![1]; + bytes.extend_from_slice(&[3; 32]); + bytes.extend_from_slice(&u64::MAX.to_le_bytes()); + bytes.extend_from_slice(&[0, 0, 0, 0]); + bytes +} +#[test] +fn exact_footer_and_portable_fact_round_trip_without_duplicate_storage() { + let wire = fixture(); + let fact = ConsensusFact::new(binding(), wire.clone()).unwrap(); + let encoded = encode(&fact).unwrap(); + assert_eq!(&encoded[..8], b"BZCNF001"); + assert_eq!(&encoded[8..12], &[1, 0, 1, 0]); + let n = u32::from_le_bytes(encoded[12..16].try_into().unwrap()) as usize; + assert_eq!(encoded.len(), HEADER_BYTES + n + wire.len()); + assert_eq!(&encoded[HEADER_BYTES + n..], wire); + let decoded = decode_bound(&encoded, &binding()).unwrap(); + assert_eq!(decoded, fact); + assert_eq!(encode(&decoded).unwrap(), encoded); + let view = decoded.footer().unwrap(); + assert_eq!(view.bank_hash, [9; 32]); + assert_ne!(view.bank_hash, decoded.binding.bank.blockhash); + assert_eq!(view.block_producer_time_nanos, 1_792_737_800_123_456_789); + assert_eq!(view.block_user_agent, b"abc"); + let cert = view.block_final_cert.unwrap(); + assert_eq!(cert.slot, 459_999_900); + assert_eq!(cert.block_id, ConsensusBlockId([10; 32])); + assert_ne!(cert.slot, decoded.binding.bank.slot); + assert_eq!(cert.final_aggregate.signature, &[0x11; 96]); + assert_eq!(cert.final_aggregate.bitmap, &[0x80, 0xff]); + assert_eq!(cert.notar_aggregate.unwrap().signature, &[0x22; 96]); + assert_eq!( + view.skip_reward_cert.unwrap().aggregate.bitmap, + &[0x55; 128] + ); + assert_eq!( + view.notar_reward_cert.unwrap().block_id, + ConsensusBlockId([11; 32]) + ); +} +#[test] +fn json_preserves_nanos_and_unknown_is_never_tower_or_empty() { + let bytes = minimal(); + let view = decode_footer(&bytes).unwrap(); + let json = serde_json::to_value(view).unwrap(); + assert_eq!(json["block_producer_time_nanos"], u64::MAX.to_string()); + assert_eq!(json["verification"], "captured_not_verified"); + assert!(json.get("consensus_block_id").is_none()); + assert!(json["block_final_cert"].is_null()); + assert!(decode_footer(&[]).is_err()); + let mut unknown = bytes.clone(); + unknown[0] = 2; + assert!(decode_footer(&unknown).is_err()); + let mut bad = bytes; + bad[42] = 2; + assert!(decode_footer(&bad).is_err()); +} +#[test] +fn maximum_user_agent_is_bytes_not_utf8() { + let mut bytes = vec![1]; + bytes.extend_from_slice(&[0; 32]); + bytes.extend_from_slice(&0u64.to_le_bytes()); + bytes.push(255); + bytes.extend_from_slice(&[255; 255]); + bytes.extend_from_slice(&[0, 0, 0]); + assert_eq!(decode_footer(&bytes).unwrap().block_user_agent, &[255; 255]); +} +#[test] +fn every_native_truncation_and_trailing_byte_is_rejected() { + let bytes = fixture(); + for cut in 0..bytes.len() { + assert!(decode_footer(&bytes[..cut]).is_err(), "{cut}"); + } + let mut trailing = bytes; + trailing.push(0); + assert!(decode_footer(&trailing).is_err()); + assert!(decode_footer(&vec![0; MAX_FOOTER_BYTES + 1]).is_err()); +} +#[test] +fn source_bank_parent_payload_and_captured_event_cannot_be_rebound() { + let encoded = encode(&ConsensusFact::new(binding(), fixture()).unwrap()).unwrap(); + for field in 0..9 { + let mut other = binding(); + match field { + 0 => other.source.session_id = "native-other".into(), + 1 => other.source.node_identity = "SysvarC1ock11111111111111111111111111111111".into(), + 2 => other.source.genesis_hash = "SysvarC1ock11111111111111111111111111111111".into(), + 3 => other.bank.bank_id += 1, + 4 => other.bank.blockhash[0] ^= 1, + 5 => other.bank.parent_blockhash[0] ^= 1, + 6 => other.execution_payload_sha256 = "ef".repeat(32), + 7 => other.source_event_sequence += 1, + _ => other.source_event_sha256 = "12".repeat(32), + } + assert!(decode_bound(&encoded, &other).is_err(), "{field}"); + } + let mut other = binding(); + other.source.agave_revision = "00".repeat(20); + assert!(ConsensusFact::new(other, minimal()).is_err()); + let mut other = binding(); + other.execution_payload_sha256 = "AB".repeat(32); + assert!(ConsensusFact::new(other, minimal()).is_err()); +} +#[test] +fn portable_header_lengths_versions_and_json_are_strict() { + let good = encode(&ConsensusFact::new(binding(), minimal()).unwrap()).unwrap(); + for cut in 0..good.len() { + assert!(decode(&good[..cut]).is_err(), "{cut}"); + } + for index in [0, 8, 10] { + let mut bad = good.clone(); + bad[index] ^= 2; + assert!(decode(&bad).is_err()); + } + for index in [12, 16] { + let mut bad = good.clone(); + bad[index..index + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(decode(&bad).is_err()); + } + let mut bad = good.clone(); + bad.push(0); + assert!(decode(&bad).is_err()); + assert!(decode(&vec![0; MAX_FACT_BYTES + 1]).is_err()); + let n = u32::from_le_bytes(good[12..16].try_into().unwrap()) as usize; + let original = std::str::from_utf8(&good[20..20 + n]).unwrap(); + for json in [ + original.replacen('{', "{\"unknown\":1,", 1), + original.replacen('{', "{\"source_event_sequence\":123,", 1), + ] { + let mut bad = good[..20].to_vec(); + bad[12..16].copy_from_slice(&(json.len() as u32).to_le_bytes()); + bad.extend(json.as_bytes()); + bad.extend(&good[20 + n..]); + assert!(decode(&bad).is_err()); + } +} +#[test] +fn option_tags_and_fixed_vs_short_bitmap_lengths_are_not_interchangeable() { + let good = fixture(); + // Tag1, hash32, nanos8, user-agent length1+3 => final option at45. + let mut bad = good.clone(); + bad[45] = 2; + assert!(decode_footer(&bad).is_err()); + let mut bad = good; + bad[45 + 1 + 8 + 32 + 96..45 + 1 + 8 + 32 + 96 + 2].copy_from_slice(&65535u16.to_le_bytes()); + assert!(decode_footer(&bad).is_err()); + for short in [ + &[0x80, 0][..], + &[0x80, 0x80, 0][..], + &[0x80, 0x80, 4][..], + &[0xff, 0xff, 0x80][..], + ] { + let mut bytes = vec![1]; + bytes.extend([0; 32]); + bytes.extend(0u64.to_le_bytes()); + bytes.extend([0, 0, 1]); + bytes.extend(0u64.to_le_bytes()); + bytes.extend([0; 96]); + bytes.extend(short); + bytes.push(0); + assert!(decode_footer(&bytes).is_err()); + } +} +#[test] +fn fixed_schema_fixture_has_stable_digest() { + assert_eq!( + blockzilla_bank_stream_v2::digest(&fixture()), + "45ec69d756fd88adf94d4ffa72ee4657b4ab74810385f8c109590722a0d8b06d" + ); +} + +#[test] +fn all_optional_native_certificate_positions_and_fast_finalization() { + for final_mode in 0..3 { + for skip in [false, true] { + for notar in [false, true] { + let mut b = vec![1]; + b.extend([4; 32]); + b.extend(123u64.to_le_bytes()); + b.push(0); + b.push(u8::from(final_mode > 0)); + if final_mode > 0 { + b.extend(5u64.to_le_bytes()); + b.extend([6; 32]); + aggregate(&mut b, 7, &[8], false); + b.push(u8::from(final_mode == 2)); + if final_mode == 2 { + aggregate(&mut b, 9, &[], false); + } + } + b.push(u8::from(skip)); + if skip { + b.extend(10u64.to_le_bytes()); + aggregate(&mut b, 11, &[], true); + } + b.push(u8::from(notar)); + if notar { + b.extend(12u64.to_le_bytes()); + b.extend([13; 32]); + aggregate(&mut b, 14, &[15], true); + } + let f = decode_footer(&b).unwrap(); + assert_eq!(f.block_final_cert.is_some(), final_mode > 0); + assert_eq!( + f.block_final_cert + .as_ref() + .is_some_and(|c| c.notar_aggregate.is_some()), + final_mode == 2 + ); + assert_eq!(f.skip_reward_cert.is_some(), skip); + assert_eq!(f.notar_reward_cert.is_some(), notar); + let value = ConsensusFact::new(binding(), b).unwrap(); + assert_eq!(decode(&encode(&value).unwrap()).unwrap(), value); + } + } + } +} +#[test] +fn verified_official_footer_grammar_revision_is_also_explicitly_admitted() { + let mut b = binding(); + b.source.agave_revision = OFFICIAL_REVISION.into(); + let f = ConsensusFact::new(b.clone(), minimal()).unwrap(); + assert_eq!(decode_bound(&encode(&f).unwrap(), &b).unwrap(), f); +} + +#[test] +fn independently_wincode_encoded_native_schema_fixture() { + // Produced by Wincode0.6.1 from the pinned native field declarations, + // not by this codec's manual fixture builder. Hash/signature wrappers are + // transparent fixed bytes. This is a schema test, not a signed live footer. + let wire = include_bytes!("fixtures/agave-4.3.2-footer-v1.wincode"); + assert_eq!( + blockzilla_bank_stream_v2::digest(wire), + "32c22ddb2fc144203ebe60c1e87226443d019b103ea798ada38e413f79f98817" + ); + let v = decode_footer(wire).unwrap(); + assert_eq!(v.bank_hash, [3; 32]); + assert_eq!(v.block_producer_time_nanos, 1_792_737_800_123_456_789); + assert_eq!(v.block_user_agent, b"pinned-schema-fixture"); + let c = v.block_final_cert.unwrap(); + assert_eq!(c.slot, 42); + assert_eq!(c.block_id, ConsensusBlockId([4; 32])); + assert_eq!(c.final_aggregate.signature, &[0x11; 96]); + assert_eq!(c.final_aggregate.bitmap, &[0xa5, 0xff]); + assert!(c.notar_aggregate.is_none()); + assert!(v.skip_reward_cert.is_none()); + assert!(v.notar_reward_cert.is_none()); + let f = ConsensusFact::new(binding(), wire.to_vec()).unwrap(); + assert_eq!(decode(&encode(&f).unwrap()).unwrap(), f); +} + +#[test] +fn actual_captured_native_footer_matches_pinned_public_provenance() { + let wire = include_bytes!("fixtures/captured-footer-3384311167.wincode"); + let proof: serde_json::Value = + serde_json::from_str(include_str!("fixtures/captured-footer-3384311167.json")).unwrap(); + assert_eq!( + blockzilla_bank_stream_v2::digest(wire), + proof["native_footer_sha256"] + ); + assert_eq!(wire.len() as u64, proof["native_footer_bytes"]); + let v = decode_footer(wire).unwrap(); + assert_eq!(v.verification, "captured_not_verified"); + assert_eq!( + v.block_producer_time_nanos.to_string(), + proof["producer_time_nanos"] + ); + let json = serde_json::to_value(&v).unwrap(); + assert_eq!( + json["block_producer_time_nanos"], + proof["producer_time_nanos"] + ); + assert!(json.get("carrier_consensus_block_id").is_none()); + let target = v.block_final_cert.unwrap(); + assert_eq!(target.slot, proof["finalization_target_slot"]); + assert_ne!(target.slot, proof["carrier_slot"]); + let mut damaged = wire.to_vec(); + damaged.push(0); + assert!(decode_footer(&damaged).is_err()); +} diff --git a/crates/blockzilla-bank-stream-v2/tests/fixtures/agave-4.3.2-footer-v1.wincode b/crates/blockzilla-bank-stream-v2/tests/fixtures/agave-4.3.2-footer-v1.wincode new file mode 100644 index 0000000..5da76f6 Binary files /dev/null and b/crates/blockzilla-bank-stream-v2/tests/fixtures/agave-4.3.2-footer-v1.wincode differ diff --git a/crates/blockzilla-bank-stream-v2/tests/fixtures/captured-footer-3384311167.json b/crates/blockzilla-bank-stream-v2/tests/fixtures/captured-footer-3384311167.json new file mode 100644 index 0000000..a90615e --- /dev/null +++ b/crates/blockzilla-bank-stream-v2/tests/fixtures/captured-footer-3384311167.json @@ -0,0 +1,21 @@ +{ + "bank_hash_hex": "c512cdbd4355a9c06d96696f63057b7fde6b113dac7a7b444a52c69d507ca5da", + "carrier_bank_id": 2403404, + "carrier_slot": 460170404, + "finalization_target_slot": 460170403, + "fixture_kind": "captured_retained_native_footer", + "native_footer_bytes": 556, + "native_footer_sha256": "b06c226b404ca15bf6c6b982e88dc951a453aefe7e27358088af04b0beb267d7", + "producer_time_nanos": "1792700777706079707", + "source": { + "agave_revision": "87167f3906bdf5111e99ead64d8f4098b7874e33", + "genesis_hash": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d", + "node_identity": "J7qpvCEWhdBgPdYRV3s3btWPrRVXa2qnAzgvxWTvtbrQ", + "session_id": "000000000000000018df884b2474cf8e-20b258" + }, + "source_coverage": "Retained event from failed source; not a complete-history or execution-payload binding proof.", + "source_event_sequence": 3384311167, + "source_event_sha256": "82c851a4521b9cb334bf86573cfa2b7a124bc9445db3ec66e483ed52e0ab8f7c", + "user_agent_hex": "61676176652f342e332e3220287372633a36616131393564613b20666561743a63396164333464322c20636c69656e743a4861726d6f6e6963416761766529", + "verification": "captured_not_verified" +} diff --git a/crates/blockzilla-bank-stream-v2/tests/fixtures/captured-footer-3384311167.wincode b/crates/blockzilla-bank-stream-v2/tests/fixtures/captured-footer-3384311167.wincode new file mode 100644 index 0000000..c329653 Binary files /dev/null and b/crates/blockzilla-bank-stream-v2/tests/fixtures/captured-footer-3384311167.wincode differ diff --git a/docs/architecture/alpenglow-consensus-facts.md b/docs/architecture/alpenglow-consensus-facts.md new file mode 100644 index 0000000..710387c --- /dev/null +++ b/docs/architecture/alpenglow-consensus-facts.md @@ -0,0 +1,195 @@ +# Alpenglow consensus facts + +Date: 23 October 2026. Profile: `native-consensus-fact-v1`. + +The first release adds an offline writer, a strict Rust reader, and a shared +Rust/Wasm reader for JavaScript. It stores retained native footer evidence in +separate immutable files. It does not change a published execution payload or +any existing archive. It is not a production deployment or capture recovery. + +## Protocol basis + +Votor is active on mainnet. The migration genesis block is slot 459652999; +Alpenglow blocks start at 459653000. Rotor has a separate later rollout. Use +`getAgGenesisCert` and source evidence to identify the migration. A date or an +Agave version alone does not prove that a captured block used Alpenglow. See +the [official migration guide](https://solana.com/upgrades/alpenglow). + +The installed plugin already records exact native `VersionedBlockFooter` +Wincode bytes. The schema in this release admits the deployed Agave source +`87167f3906bdf5111e99ead64d8f4098b7874e33` and upstream Agave 4.3.2 commit +`a203300045b405f86c9c84308b2eae76af69ac3b`. Their footer types match. +See the pinned [footer types](https://github.com/anza-xyz/agave/blob/a203300045b405f86c9c84308b2eae76af69ac3b/entry/src/block_component.rs#L238-L361) +and [reward certificate types](https://github.com/anza-xyz/agave/blob/a203300045b405f86c9c84308b2eae76af69ac3b/votor-messages/src/reward_certificate.rs#L23-L88). + +## Separate identities and evidence + +| Value | Meaning | +| --- | --- | +| Source four-tuple and `(slot, bank_id)` | One node-local bank in one capture session | +| Execution blockhash and parent blockhash | Last-entry hashes used by the existing execution payload and expiry logic | +| Footer bank hash | Producer's expected execution state hash | +| Consensus `BlockId` | A distinct consensus identity; a certificate carries the identity of its own target | +| Execution payload SHA256 | Digest of the exact execution frame bytes that this fact supplements | +| Source event sequence and SHA256 | Position and digest of the exact captured JSON event, including its callback envelope | + +A footer does not contain its carrier's consensus `BlockId` or parent +`BlockId`. Readers report `carrier_consensus_block_id: null`. They must not +substitute the execution blockhash, bank hash, or a certificate target. +The certificate target can be older than its carrying bank. The native +[block ID rule](https://github.com/anza-xyz/agave/blob/a203300045b405f86c9c84308b2eae76af69ac3b/ledger/src/blockstore.rs#L3653-L3689) +also differs between Tower and Alpenglow. + +The old captured `ClearBank.parent_blockhash` field contains the consensus +parent `BlockId` from `UpdateParent`. A later event version must give it an +accurate type and name. Do not reinterpret or rewrite historical event bytes. + +Each fact retains the raw native footer once. Readers derive these fields: + +- Bank hash, exact producer Unix nanoseconds, and raw user-agent bytes. +- Optional block finalization certificate with its own slot and consensus + `BlockId`, final aggregate, and optional notarization aggregate. +- Optional skip reward and notarization reward aggregates as separate types. + +All signatures and bitmaps remain exact captured bytes. The verification +state is always `captured_not_verified`. A reward aggregate does not establish +finality or a skipped slot. This release does not verify BLS signatures, stake +thresholds, epoch ranks, bitmap meaning, or producer time bounds. JavaScript +receives nanoseconds and certificate target slots as decimal strings. It can +use `BigInt` for exact arithmetic. + +## Wire contract + +The portable header is 20 bytes, in this order: + +| Field | Encoding | +| --- | --- | +| Magic | Eight bytes: `BZCNF001` | +| Portable version | Little-endian u16, value 1 | +| Native grammar code | Little-endian u16, value 1 | +| Binding JSON byte length | Little-endian u32, 1 to 4096 | +| Native footer byte length | Little-endian u32, 1 to 65535 | + +The binding JSON and exact native footer follow the header. The binding has +`source`, `bank`, `execution_payload_sha256`, `source_event_sequence`, and +`source_event_sha256`. Source and bank use the existing strict native bank v2 +identities. The JSON accepts no unknown fields. The footer contains only native +`VersionedBlockFooter::V1`, without the outer block marker or length prefix. + +The reader rejects an unsupported profile version, schema, source revision, +invalid option tag, truncated value, noncanonical reward bitmap length, +oversized field, or trailing bytes. Finality bitmaps have a fixed u16 length; +reward bitmaps use ShortU16. These encodings are not interchangeable. +The footer limit follows the native marker's enclosing u16 byte length. + +`decode_bound` checks the full binding supplied by the caller. A consumer must +also check the complete fact digest from a trusted export receipt. A digest +proves byte identity; it does not independently prove the source's claims. + +## Writer and reader + +The direct Agave CLI provides four commands: + +```sh +hivezilla-direct-agave export-consensus binding.json footer-event.json execution-frame.bin export-dir +hivezilla-direct-agave export-consensus-source-events execution-binding.json source-events.json.zst execution-frame.bin export-dir +hivezilla-direct-agave inspect-consensus-export export-dir +hivezilla-direct-agave inspect-consensus-fact fact.bin binding.json +``` + +The single-event command accepts an exact `Event::Footer` JSON value or one +`RawEvidence` record. Its binding includes the exact event sequence and hash. +The source-events command accepts the retained per-bank JSON array or its +Zstandard form. Its execution binding contains only `source`, `bank`, and +`execution_payload_sha256`; each footer record supplies its own event binding. +Export accepts native bank v2 block frames. Other execution profiles fail +admission. Source-events input is bounded to 256 MiB, both stored and decoded. +Each export has at most 1024 facts and 1 MiB of raw footer bytes in total. +The writer checks input bounds and exact hashes before it writes a fact. +It rejects a source, bank, payload, or event mismatch. It creates immutable +fact files and an export receipt. An equal retry can reuse equal bytes; it +cannot replace conflicting output. + +The JavaScript entry point is `@blockzilla/stream-transport/consensus`: + +```js +import { loadConsensusDecoder, decodeConsensusFact } from './consensus.mjs'; +const decoder = await loadConsensusDecoder(); +const result = await decodeConsensusFact(factBytes, receiptDigest, expectedBinding, decoder); +const timeNanos = BigInt(result.decoded.footer.block_producer_time_nanos); +``` + +The SDK uses the same Rust parser as the native tools. It does not implement a +second native schema in JavaScript or change the bank client's root state. + +An export receipt grants **no source retirement authority**. Existing compact +markers, custody records, storage ledgers, caps, and archives remain separate. +The exporter can read retained evidence; it cannot claim that previously +retired events are available or that an entire epoch has footer coverage. + +## Legacy field policy + +For new Alpenglow consensus records, omit Tower lockout depths, the old +64-tick timing model, and a separate confirmed-to-finalized delay. Do not copy +execution transactions or old PoH timing fields into each consensus fact. +The new profile contains only the source binding and exact footer evidence. + +Keep transaction entries, counts, hashes, source gaps, execution parent links, +block height, expiry, rewards, and fork invalidation. Agave 4.3.2 still requires +a terminal empty Alpentick with `num_hashes == 1`. Footer arrival precedes that +entry callback. It does not prove block completeness. The current execution +format's `available-poh.wincode` name stays unchanged to preserve its published +contract; its retained entry records are not an independent PoH timing proof. +See the [terminal entry rule](https://github.com/anza-xyz/agave/blob/a203300045b405f86c9c84308b2eae76af69ac3b/runtime/src/block_component_processor.rs#L364-L390) +and [callback ordering regression](https://github.com/anza-xyz/agave/blob/a203300045b405f86c9c84308b2eae76af69ac3b/ledger/src/blockstore_processor.rs#L5814-L5893). + +Historical Tower readers and existing immutable archives remain supported. +Do not discard a real transaction because its program or `is_vote` flag looks +legacy. Do not infer Alpenglow from a missing footer. + +## Delivery and production plan + +1. **This release:** strict footer fact codec, offline immutable export and + inspection, shared Rust/Wasm JavaScript reader, tests and retained native + fixture checks. Existing execution v1/v2 byte contracts stay separate. +2. **Continuous evidence release:** give footer export its own durable source + cursor, conflict detection, inventory, cloud upload/readback proof, and + retention acknowledgement. Test a footer that arrives after the first rooted + custody receipt and a crash at each commit boundary. The current first + `CUSTODY.json` is immutable and does not cover later callbacks. A journal-only + cursor must not skip retired prefixes or claim full historical coverage. +3. **Live format release:** negotiate a new profile with the Rust relay, Wasm + reader, JavaScript SDK, publisher, and archive catalog. Reuse execution + payloads and send small fact references. Give entry evidence an accurate + name in that new contract. Keep explicit unknown/unsupported states. Add + consensus parent/header and carrier `BlockId` only from a proven native + source; the current footer callback cannot supply them. + Full Votor vote/skip-certificate capture needs another native source. + Independent certificate verification also needs exact epoch ranks, stakes, + registered BLS keys and proof-of-possession provenance, bitmap rules and + signature domains. Keep it distinct from a node's confirmation callback. +4. **Controlled deployment:** repair the source/registry/writable-path handoff, + validate free capacity over time, preserve the failed source and its gap, + and start a new capture session. Then prove actual capture, compaction, + publication, client decode, cloud readback, restart, and retirement progress. + Only enable new source retirement after all required fact custody is proved. + +The failed 22 October capture cannot be made complete by a worker restart. +Format tests do not recover that capture or establish current live block flow. +Production cutover needs the coordinated storage handoff described in the +[deployment record](../../hivezilla/deploy/direct-agave/README.md). + +## Acceptance + +The local targeted suites passed 104 tests: 21 portable codec tests, 41 native +library tests, 13 execution decoder tests and 29 JavaScript tests. Four explicit +helpers remain ignored in the normal runs: two require the real compact writer, +one exports decoder fixtures, and one is invoked by the relative-path test in +a subprocess. The WebAssembly release build passed. A real retained 556-byte +footer and an independent Wincode fixture test the native grammar. These tests +do not establish full historical coverage or production progress. + +The CI job is configured to build the consensus WebAssembly reader from source +and test it with the v1/v2/consensus JavaScript suites. Read the +[acceptance receipt](../operations/evidence/alpenglow-format-20261023.json) +for artifact hashes, commands and limits. diff --git a/docs/operations/evidence/alpenglow-format-20261023.json b/docs/operations/evidence/alpenglow-format-20261023.json new file mode 100644 index 0000000..ad12b7c --- /dev/null +++ b/docs/operations/evidence/alpenglow-format-20261023.json @@ -0,0 +1,293 @@ +{ + "schema": "blockzilla-alpenglow-format-acceptance-v1", + "saved_utc": "2026-10-23T08:43:53.353584+00:00", + "state": "IMPLEMENTED_TESTED_NOT_DEPLOYED", + "base_commit": "b01231ce80c740730478198e521eac9367a11495", + "branch": "codex/alpenglow-footer-facts", + "profile": "native-consensus-fact-v1", + "owner_legacy_policy": "Remove obsolete fields from the new format; keep existing archives", + "implemented": [ + "Bounded portable source/bank/execution/event-bound native footer codec", + "Immutable offline native bank-v2 footer exporter and receipt inspection", + "Strict shared Rust/Wasm and JavaScript reader with exact decimal nanoseconds", + "CI source rebuild and SDK tests configured", + "Versioned wire contract and controlled live migration plan" + ], + "validation": { + "rustc": "1.99.0 (b940084d7 2026-09-28)", + "node": "26.12.0", + "build_jobs": 2, + "codec": { + "command": "cargo test --manifest-path crates/blockzilla-bank-stream-v2/Cargo.toml --locked --offline", + "passed": 21, + "failed": 0 + }, + "native": { + "command": "cargo test --manifest-path hivezilla/direct-agave/Cargo.toml --locked --offline --lib --bins", + "passed": 41, + "failed": 0, + "ignored": 3 + }, + "execution_decoder": { + "command": "cargo test --manifest-path hivezilla/stream-relay/decoder/Cargo.toml --locked --lib", + "passed": 13, + "failed": 0, + "ignored": 1 + }, + "wasm": { + "command": "cargo build --manifest-path hivezilla/stream-relay/decoder/Cargo.toml --locked --offline --release --target wasm32-unknown-unknown", + "result": "PASS", + "artifact": "hivezilla/stream-relay/client/decoder-consensus-v1.wasm" + }, + "javascript": { + "command": "node --test test.mjs sdk-test.mjs banks-test.mjs consensus-test.mjs", + "working_directory": "hivezilla/stream-relay/client", + "passed": 29, + "failed": 0 + }, + "total_passed": 104, + "ignored_reasons": [ + "Two existing native tests require the real compact writer", + "One native helper is invoked by the relative-output test in a subprocess", + "One decoder helper exports fixtures on explicit request" + ], + "cli_smoke": { + "commands": [ + "--help", + "inspect-consensus-fact" + ], + "result": "PASS", + "historical_execution_payload_binding_proven": false + }, + "formatting": "Changed files PASS; whole manifests also report pre-existing processed.rs and publisher.rs formatting failures", + "staged_diff_check": "PASS", + "ci_yaml_parse": "PASS", + "independent_protocol_and_code_review": "PASS", + "ci_when_saved": "INITIAL_RUN_FAILED; own module-order issue corrected; pre-existing main failures remain visible", + "ci_baseline": { + "main_commit": "b01231ce80c740730478198e521eac9367a11495", + "main_run": "https://github.com/FernoLabs/blockzilla/actions/runs/39429692313", + "pr_initial_run": "https://github.com/FernoLabs/blockzilla/actions/runs/39926960388", + "baseline_failure_categories": [ + "processed.rs formatting", + "publisher.rs formatting", + "Missing local hivezilla/deploy/validator-tunnel/access-application.json link target", + "Reachable-history scanner:135 findings on main and initial PR run" + ], + "individual_history_findings_compared": false, + "checks_weakened": false + } + }, + "native_fixture": { + "bank_hash_hex": "c512cdbd4355a9c06d96696f63057b7fde6b113dac7a7b444a52c69d507ca5da", + "carrier_bank_id": 2403404, + "carrier_slot": 460170404, + "finalization_target_slot": 460170403, + "fixture_kind": "captured_retained_native_footer", + "native_footer_bytes": 556, + "native_footer_sha256": "b06c226b404ca15bf6c6b982e88dc951a453aefe7e27358088af04b0beb267d7", + "producer_time_nanos": "1792700777706079707", + "source": { + "agave_revision": "87167f3906bdf5111e99ead64d8f4098b7874e33", + "genesis_hash": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d", + "node_identity": "J7qpvCEWhdBgPdYRV3s3btWPrRVXa2qnAzgvxWTvtbrQ", + "session_id": "000000000000000018df884b2474cf8e-20b258" + }, + "source_coverage": "Retained event from failed source; not a complete-history or execution-payload binding proof.", + "source_event_sequence": 3384311167, + "source_event_sha256": "82c851a4521b9cb334bf86573cfa2b7a124bc9445db3ec66e483ed52e0ab8f7c", + "user_agent_hex": "61676176652f342e332e3220287372633a36616131393564613b20666561743a63396164333464322c20636c69656e743a4861726d6f6e6963416761766529", + "verification": "captured_not_verified" + }, + "limits": [ + "Offline per-bank export; no continuous late-evidence cursor or cloud custody", + "No source-retirement authority", + "No BLS, stake/quorum, producer clock or independent finality verification", + "Footer has no carrier consensus BlockId or parent BlockId", + "Retained failed-source evidence does not prove complete historical coverage", + "Existing v1/v2 execution contracts and archives stay separate", + "Local tests do not prove the validator plugin ABI or production recovery" + ], + "production_mutations_by_this_change": false, + "archives_deleted_by_this_change": false, + "files": [ + { + "path": ".github/workflows/ci.yml", + "bytes": 8663, + "sha256": "afdac19eeb620475d6dbec22e6f16f0b9d450d6a56f46f75e958529f91ededec" + }, + { + "path": "crates/blockzilla-bank-stream-v2/src/consensus.rs", + "bytes": 11607, + "sha256": "616dec11ce823a5c3a62b9e4403007d0fdb2b90ee11d0b6df6563ba9278b72dd" + }, + { + "path": "crates/blockzilla-bank-stream-v2/src/lib.rs", + "bytes": 23611, + "sha256": "35cf5ce103f4ee7e0196ed59db2e85671ce9ac153168a07fa8ed95ae484d23d4" + }, + { + "path": "crates/blockzilla-bank-stream-v2/tests/consensus.rs", + "bytes": 13550, + "sha256": "ebda662e6f40ae49cc562287ff60266f5e53bbcde78b698e6e311f82147eeba1" + }, + { + "path": "crates/blockzilla-bank-stream-v2/tests/fixtures/agave-4.3.2-footer-v1.wincode", + "bytes": 207, + "sha256": "32c22ddb2fc144203ebe60c1e87226443d019b103ea798ada38e413f79f98817" + }, + { + "path": "crates/blockzilla-bank-stream-v2/tests/fixtures/captured-footer-3384311167.json", + "bytes": 1140, + "sha256": "a8390deab450d66055f048257829f1040c3319453c1566093e600abb42b8cf9a" + }, + { + "path": "crates/blockzilla-bank-stream-v2/tests/fixtures/captured-footer-3384311167.wincode", + "bytes": 556, + "sha256": "b06c226b404ca15bf6c6b982e88dc951a453aefe7e27358088af04b0beb267d7" + }, + { + "path": "docs/architecture/alpenglow-consensus-facts.md", + "bytes": 11377, + "sha256": "8586ce678a0c4d9abc9659c7fdf8ed501b3b88e1cef15fb6dda0a99f73e1ac55" + }, + { + "path": "hivezilla/direct-agave/README.md", + "bytes": 10571, + "sha256": "25ff1cad5c920bb3e0f1f7d44e2c891f139f2eb6f915304b169f6de847b5a689" + }, + { + "path": "hivezilla/direct-agave/src/consensus.rs", + "bytes": 22061, + "sha256": "91a78b8c1cd27451226aff44313c8eee69ed72ebfd2ffa7bee0afcb5f8bc11cb" + }, + { + "path": "hivezilla/direct-agave/src/consensus_tests.rs", + "bytes": 23982, + "sha256": "c1a554b7fd58726da8f0f4f592bcc310ed690c8bfb347ffbd811fa0a882e90ff" + }, + { + "path": "hivezilla/direct-agave/src/lib.rs", + "bytes": 259, + "sha256": "4a055aac1e22aa051359e59cf8d47528eb697de01691fc2844784cf4d740252b" + }, + { + "path": "hivezilla/direct-agave/src/main.rs", + "bytes": 6399, + "sha256": "a037d59b20ddb5d93303f815b70574e76e2ea4a5704edb6ccbb6b149511820fd" + }, + { + "path": "hivezilla/stream-relay/client/README.md", + "bytes": 6298, + "sha256": "28cfde1cbf4b2d0b671fde37f11b3ed1f9af8979a109f41b4a5da470fc7520bc" + }, + { + "path": "hivezilla/stream-relay/client/consensus-test.mjs", + "bytes": 5236, + "sha256": "95c01a09b8048f26fd683ea931cdeaed31d3b7fe141df5350ddc41a85f3e7ed8" + }, + { + "path": "hivezilla/stream-relay/client/consensus.d.mts", + "bytes": 1876, + "sha256": "cb64926e7e1b04473cc18743d383b855d7f3ff9acd0215b421b2f3957885ae2f" + }, + { + "path": "hivezilla/stream-relay/client/consensus.mjs", + "bytes": 3940, + "sha256": "feb41a0b5e7ae6d948ca1c99e728eb1377bf364547ced3f6b1035f93020b1e14" + }, + { + "path": "hivezilla/stream-relay/client/decoder-consensus-v1.wasm", + "bytes": 821314, + "sha256": "4d8e99c58c7d2eb9ff1c4f977e6cdc0deab8180c9c4d41d00ee36ad9f3865451" + }, + { + "path": "hivezilla/stream-relay/client/package.json", + "bytes": 557, + "sha256": "03b44a952ae8b5976cb31e046053a2032f735ded18d968ef9b446355b1f3ebee" + }, + { + "path": "hivezilla/stream-relay/decoder/README.md", + "bytes": 2449, + "sha256": "aba1b97c1bebb168e2b15b8bc7b82bd3f49df13eed9964b6b40c1569cd91878d" + }, + { + "path": "hivezilla/stream-relay/decoder/src/consensus.rs", + "bytes": 1692, + "sha256": "022308d747998929326f4970109f7845be52138999748f920830fe721d7b6808" + }, + { + "path": "hivezilla/stream-relay/decoder/src/lib.rs", + "bytes": 29739, + "sha256": "0f222c908ae694bfbb508b7516748bb1ce697cc5f72ae7d8bedd6ed961125d5a" + } + ], + "local_evidence_logs": [ + { + "local_evidence": "format-review-agent/TEST-FINAL.log", + "bytes": 2435, + "sha256": "fa2f49bcae9a01e8ea74928ea4548ee45a27b52fdbe2e3f23c30086d0671305d" + }, + { + "local_evidence": "native-tests.log", + "bytes": 11588, + "sha256": "c2261dfb45f0a05e919153a697705532039c92acb466e36b6ef4718bb9947841" + }, + { + "local_evidence": "native-tests-final.log", + "bytes": 5873, + "sha256": "5596f17d628e9898900ee8a48fbb3d845285c0341927e45465567f10ef432162" + }, + { + "local_evidence": "wasm-build-final.log", + "bytes": 1720, + "sha256": "e9feb245df588e592b745bb64896e0e9425e38ec89f0e765830994c7136f83d0" + }, + { + "local_evidence": "client-tests-final.log", + "bytes": 2505, + "sha256": "c3b7ad4ab89c4180f71565fb69304277fdcb30f267b65e9db8d24536d27eff61" + }, + { + "local_evidence": "native-cli-build.log", + "bytes": 1559, + "sha256": "d045eebf557ba38175eda1d36ab4bba31a0e92f31e143dbd8d591d719e2d2e49" + }, + { + "local_evidence": "cli-smoke/inspection.json", + "bytes": 3290, + "sha256": "7f65e3b7dde56b5fc1baddeec869b3b96f36cc62ddcd44c625e3e926d2c2bfc4" + }, + { + "local_evidence": "wasm-build-after-ci.log", + "bytes": 1871, + "sha256": "3ef7fdcbdd9b81a724e21d244961d0ad38fa370fca3d137dce8ef5f92f1a37e6" + }, + { + "local_evidence": "client-tests-after-ci.log", + "bytes": 2489, + "sha256": "da41c42b860cd7231a12bcda3565b18ff425135515a4e536f46e6877b6b13a45" + }, + { + "local_evidence": "ci-stream-39926960388.log", + "bytes": 17184, + "sha256": "1271feaaccfaca33ae8093a212e4010dd7f89b682e6e1979bfacbd4fc5039960" + }, + { + "local_evidence": "ci-rust-39926960388.log", + "bytes": 51574, + "sha256": "f26105281b48320bfb8192a93c577b89ba1b6c18e152de504b846f7f1c32196b" + }, + { + "local_evidence": "ci-docs-39926960388.log", + "bytes": 25294, + "sha256": "ad1a68f3de7b7b3ce834525cc99ff4c82ec34044ffe88c9687914221b257a06a" + }, + { + "local_evidence": "ci-secrets-39926960388.log", + "bytes": 18036, + "sha256": "2939a9d0645b668f2294f599c8b00d848176dcf504b2b030d188ea8748f8e6bb" + } + ], + "updated_utc": "2026-10-23T08:54:03.772317+00:00" +} diff --git a/hivezilla/direct-agave/README.md b/hivezilla/direct-agave/README.md index 2fc60f3..90faee5 100644 --- a/hivezilla/direct-agave/README.md +++ b/hivezilla/direct-agave/README.md @@ -28,6 +28,25 @@ A plugin must use the same Rust compiler and dependency types as its validator. A successful build alone does not establish dynamic ABI compatibility. Test a bounded load beside Yellowstone before continuous use. +## Offline Alpenglow footer export + +The separate `native-consensus-fact-v1` profile stores exact retained Agave 4.3.2 +footer bytes with a source, bank, execution frame digest, and source event +binding. It derives leader nanoseconds, user-agent bytes, and optional finality +and reward aggregates. Their state is `captured_not_verified`. It does not +rewrite execution blocks or grant source retirement authority. + +Use `export-consensus`, `export-consensus-source-events`, +`inspect-consensus-export`, and `inspect-consensus-fact` for offline export and +inspection. Export requires a matching complete native bank v2 execution frame. +The source-events input can be uncompressed JSON or Zstandard JSON. Existing +archives stay unchanged. Read the [wire contract, commands, field policy and +production plan](../../docs/architecture/alpenglow-consensus-facts.md). + +Continuous late-footer export, cloud custody and a new live profile are later +steps. The current first rooted custody receipt does not cover callbacks that +arrive after it. Offline export does not restore a failed capture session. + ## Durability and failure contract Callbacks copy the transaction wire bytes, all native status metadata fields, diff --git a/hivezilla/direct-agave/src/consensus.rs b/hivezilla/direct-agave/src/consensus.rs new file mode 100644 index 0000000..50b12b8 --- /dev/null +++ b/hivezilla/direct-agave/src/consensus.rs @@ -0,0 +1,638 @@ +//! Offline export of already captured native footers. Export receipts do not +//! authorize source retirement, cloud custody, or a finality transition. +use anyhow::{Context, Result, ensure}; +use blockzilla_bank_stream_v2::{self as v2, BankIdentity, SourceIdentity}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; +use v2::consensus::{self as codec, ConsensusFact, FactBinding}; + +pub const RECEIPT: &str = "CONSENSUS-EXPORT.v1.json"; +pub const MAX_EVENT_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_SOURCE_BYTES: usize = 256 * 1024 * 1024; +pub const MAX_FACTS: usize = 1024; +pub const MAX_TOTAL_FOOTER_BYTES: usize = 1024 * 1024; +const MAX_RECORDS: usize = 1_000_000; +const MAX_RECEIPT_BYTES: usize = 4 * 1024 * 1024; +const SCHEMA: &str = "hivezilla-native-consensus-export-v1"; +const DOMAIN: &str = "exact execution frame bytes"; +const LOCK: &str = "EXPORT.lock"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutionBinding { + pub source: SourceIdentity, + pub bank: BankIdentity, + pub execution_payload_sha256: String, +} +impl ExecutionBinding { + fn validate(&self) -> Result<()> { + self.source.validate()?; + self.bank.validate()?; + ensure!( + v2::is_digest(&self.execution_payload_sha256), + "invalid_execution_digest" + ); + Ok(()) + } + fn matches(&self, fact: &FactBinding) -> bool { + self.source == fact.source + && self.bank == fact.bank + && self.execution_payload_sha256 == fact.execution_payload_sha256 + } +} +impl From<&FactBinding> for ExecutionBinding { + fn from(binding: &FactBinding) -> Self { + Self { + source: binding.source.clone(), + bank: binding.bank.clone(), + execution_payload_sha256: binding.execution_payload_sha256.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InputIdentity { + pub kind: String, + /// Digest and size of the supplied file, including compression if present. + pub bytes: usize, + pub sha256: String, + pub decoded_bytes: usize, + pub decoded_sha256: String, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FactFile { + pub path: String, + pub bytes: usize, + pub sha256: String, + pub binding: FactBinding, + pub footer_bytes: usize, + pub footer_sha256: String, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExportReceipt { + pub schema: String, + pub state: String, + pub profile: String, + pub execution: ExecutionBinding, + pub execution_digest_domain: String, + pub execution_frame_bytes: usize, + pub input: InputIdentity, + pub facts: Vec, + pub verification: String, + pub no_source_retirement_authority: bool, + pub continuous_late_evidence_coverage: bool, +} +#[derive(Debug, Serialize)] +pub struct ExportInspection { + pub receipt: ExportReceipt, + /// The receipt and its files agree. An external expected binding is still + /// needed to establish provenance; inspection does not authenticate input. + pub local_hashes_consistent: bool, + pub provenance_authenticated: bool, + pub footers: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + sequence: u64, + sha256: String, + event: Box, +} +#[derive(Deserialize)] +struct EventKey { + kind: String, + slot: u64, + bank_id: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FooterEvent { + kind: String, + slot: u64, + bank_id: u64, + wire_footer: Vec, +} + +fn open_regular(path: &Path) -> Result { + ensure!( + fs::symlink_metadata(path)?.file_type().is_file(), + "input_not_regular_file" + ); + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = options.open(path)?; + ensure!(file.metadata()?.is_file(), "input_not_regular_file"); + Ok(file) +} +fn bounded_file(path: &Path, limit: usize) -> Result> { + let file = open_regular(path)?; + ensure!( + file.metadata()?.len() <= limit as u64, + "input_file_too_large" + ); + let mut bytes = Vec::new(); + file.take(limit as u64 + 1).read_to_end(&mut bytes)?; + ensure!(bytes.len() <= limit, "input_file_too_large"); + Ok(bytes) +} +fn verify_execution(binding: &ExecutionBinding, payload_path: &Path) -> Result { + binding.validate()?; + let bytes = bounded_file(payload_path, v2::MAX_FRAME)?; + ensure!( + v2::digest(&bytes) == binding.execution_payload_sha256, + "execution_frame_digest_mismatch" + ); + let frame = v2::decode(bytes.into())?; + ensure!( + frame.envelope.event.kind == "block" + && frame.source == binding.source + && frame.envelope.event.bank == binding.bank, + "execution_frame_binding_mismatch" + ); + Ok(frame.wire.len()) +} +fn footer(binding: &FactBinding, raw: &[u8]) -> Result { + binding.validate()?; + ensure!( + raw.len() <= MAX_EVENT_BYTES && v2::digest(raw) == binding.source_event_sha256, + "source_event_digest_mismatch" + ); + let event: FooterEvent = serde_json::from_slice(raw)?; + ensure!( + event.kind == "Footer" + && event.slot == binding.bank.slot + && event.bank_id == binding.bank.bank_id, + "footer_event_binding_mismatch" + ); + ConsensusFact::new(binding.clone(), event.wire_footer) +} +fn add_footer_bytes(total: &mut usize, bytes: usize) -> Result<()> { + *total = total.checked_add(bytes).context("footer_total_overflow")?; + ensure!(*total <= MAX_TOTAL_FOOTER_BYTES, "footer_total_too_large"); + Ok(()) +} + +fn input_identity(kind: &str, stored: &[u8], decoded: &[u8]) -> InputIdentity { + InputIdentity { + kind: kind.into(), + bytes: stored.len(), + sha256: v2::digest(stored), + decoded_bytes: decoded.len(), + decoded_sha256: v2::digest(decoded), + } +} + +/// Export exactly one Footer from an exact raw Event file or one RawEvidence +/// record. A supplied marker digest cannot substitute for the actual v2 frame. +pub fn export_event( + binding: &FactBinding, + event_path: &Path, + payload_path: &Path, + output: &Path, +) -> Result { + binding.validate()?; + let execution = ExecutionBinding::from(binding); + let frame_bytes = verify_execution(&execution, payload_path)?; + let input = bounded_file(event_path, MAX_EVENT_BYTES)?; + let shape: serde_json::Value = serde_json::from_slice(&input)?; + let (kind, fact) = if shape.get("kind").is_some() { + ("raw-footer-event", footer(binding, &input)?) + } else { + let record: Record = serde_json::from_slice(&input)?; + ensure!( + record.sequence == binding.source_event_sequence + && record.sha256 == binding.source_event_sha256, + "source_record_binding_mismatch" + ); + ( + "raw-evidence-record", + footer(binding, record.event.get().as_bytes())?, + ) + }; + publish( + output, + execution, + frame_bytes, + input_identity(kind, &input, &input), + vec![fact], + ) +} + +/// Export each captured Footer from one bank's retained source-events array. +/// Source identity comes from the caller and must also match the actual frame. +/// All records must have the same slot and exact bank (or a slot-only status). +pub fn export_source_events( + binding: &ExecutionBinding, + source_events_path: &Path, + payload_path: &Path, + output: &Path, +) -> Result { + let frame_bytes = verify_execution(binding, payload_path)?; + let stored = bounded_file(source_events_path, MAX_SOURCE_BYTES)?; + let compressed = stored.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]); + let decoded = if compressed { + let mut bytes = Vec::new(); + zstd::stream::read::Decoder::new(stored.as_slice())? + .take(MAX_SOURCE_BYTES as u64 + 1) + .read_to_end(&mut bytes)?; + ensure!( + bytes.len() <= MAX_SOURCE_BYTES, + "source_events_decoded_too_large" + ); + bytes + } else { + stored.clone() + }; + let records: Vec = serde_json::from_slice(&decoded)?; + ensure!( + !records.is_empty() && records.len() <= MAX_RECORDS, + "source_record_count_invalid" + ); + let mut after = 0; + let mut facts = Vec::new(); + let mut total_footer_bytes = 0; + for record in records { + ensure!( + record.sequence > after && record.sequence <= v2::SAFE && v2::is_digest(&record.sha256), + "source_record_order_invalid" + ); + after = record.sequence; + let raw = record.event.get().as_bytes(); + ensure!( + raw.len() <= MAX_EVENT_BYTES && v2::digest(raw) == record.sha256, + "source_event_digest_mismatch" + ); + let key: EventKey = serde_json::from_slice(raw)?; + ensure!( + key.slot == binding.bank.slot + && (key.bank_id == Some(binding.bank.bank_id) + || (key.bank_id.is_none() + && matches!(key.kind.as_str(), "Status" | "BankStatusV2"))), + "source_events_mixed_bank" + ); + ensure!( + matches!( + key.kind.as_str(), + "BankStateV2" + | "BankStatusV2" + | "Transaction" + | "Entry" + | "Block" + | "Status" + | "ClearBank" + | "Footer" + ), + "unknown_source_event" + ); + if key.kind == "Footer" { + ensure!(facts.len() < MAX_FACTS, "too_many_footer_facts"); + let fact_binding = FactBinding { + source: binding.source.clone(), + bank: binding.bank.clone(), + execution_payload_sha256: binding.execution_payload_sha256.clone(), + source_event_sequence: record.sequence, + source_event_sha256: record.sha256, + }; + let fact = footer(&fact_binding, raw)?; + add_footer_bytes(&mut total_footer_bytes, fact.footer_wire().len())?; + facts.push(fact); + } + } + ensure!(!facts.is_empty(), "source_events_have_no_footer"); + let kind = if compressed { + "source-events-json-zstd" + } else { + "source-events-json" + }; + publish( + output, + binding.clone(), + frame_bytes, + input_identity(kind, &stored, &decoded), + facts, + ) +} + +fn fact_name(binding: &FactBinding) -> String { + format!( + "{:020}-{}.bzcnf", + binding.source_event_sequence, binding.source_event_sha256 + ) +} +fn directory_files(output: &Path, expected: &BTreeSet) -> Result<()> { + ensure!( + fs::symlink_metadata(output)?.file_type().is_dir(), + "output_not_directory" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + ensure!( + fs::metadata(output)?.permissions().mode() & 0o022 == 0, + "output_directory_writable_by_others" + ); + } + let mut count = 0; + for row in fs::read_dir(output)? { + count += 1; + ensure!(count <= MAX_FACTS + 1024, "output_directory_too_large"); + let row = row?; + let name = row + .file_name() + .into_string() + .map_err(|_| anyhow::anyhow!("invalid_output_name"))?; + ensure!( + name == LOCK + || name == RECEIPT + || expected.contains(&name) + || name.starts_with(".consensus-tmp-"), + "output_contains_unrelated_files" + ); + ensure!( + row.file_type()?.is_file(), + "output_contains_nonregular_file" + ); + } + Ok(()) +} +fn output_lock(output: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::{fs::OpenOptionsExt, io::AsRawFd}; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + let file = options.open(output.join(LOCK))?; + ensure!(file.metadata()?.is_file(), "export_lock_not_regular"); + ensure!( + unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0, + "consensus_export_already_active" + ); + Ok(file) + } + #[cfg(not(unix))] + { + anyhow::bail!("consensus_export_requires_file_lock") + } +} +struct Scratch(PathBuf); +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} +fn create_immutable(output: &Path, name: &str, bytes: &[u8]) -> Result<()> { + let destination = output.join(name); + if fs::symlink_metadata(&destination).is_ok() { + ensure!( + bounded_file(&destination, bytes.len())? == bytes, + "existing_export_file_conflict" + ); + return Ok(()); + } + let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let scratch = Scratch(output.join(format!(".consensus-tmp-{}-{nonce}", std::process::id()))); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&scratch.0)?; + file.write_all(bytes)?; + file.sync_all()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o444))?; + file.sync_all()?; + } + // link creates the destination atomically and never replaces it. A crash + // can leave only an uncommitted scratch name, not a partial fact/receipt. + match fs::hard_link(&scratch.0, &destination) { + Ok(()) => (), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + ensure!( + bounded_file(&destination, bytes.len())? == bytes, + "existing_export_file_conflict" + ); + } + Err(error) => return Err(error.into()), + } + fs::remove_file(&scratch.0)?; + File::open(output)?.sync_all()?; + Ok(()) +} +fn publish( + output: &Path, + execution: ExecutionBinding, + frame_bytes: usize, + input: InputIdentity, + facts: Vec, +) -> Result { + ensure!( + !facts.is_empty() && facts.len() <= MAX_FACTS, + "invalid_footer_fact_count" + ); + let mut encoded = Vec::new(); + let mut files = Vec::new(); + let mut previous = 0; + let mut bank_footer_sha: Option = None; + let mut total_footer_bytes = 0; + for fact in facts { + ensure!( + execution.matches(&fact.binding) && fact.binding.source_event_sequence > previous, + "export_fact_binding_mismatch" + ); + previous = fact.binding.source_event_sequence; + add_footer_bytes(&mut total_footer_bytes, fact.footer_wire().len())?; + let footer_sha = v2::digest(fact.footer_wire()); + ensure!( + bank_footer_sha + .as_ref() + .is_none_or(|old| old == &footer_sha), + "conflicting_bank_footer" + ); + bank_footer_sha = Some(footer_sha.clone()); + let bytes = codec::encode(&fact)?; + files.push(FactFile { + path: fact_name(&fact.binding), + bytes: bytes.len(), + sha256: v2::digest(&bytes), + binding: fact.binding.clone(), + footer_bytes: fact.footer_wire().len(), + footer_sha256: footer_sha, + }); + encoded.push(bytes); + } + let receipt = ExportReceipt { + schema: SCHEMA.into(), + state: "COMPLETE".into(), + profile: codec::PROFILE.into(), + execution, + execution_digest_domain: DOMAIN.into(), + execution_frame_bytes: frame_bytes, + input, + facts: files, + verification: codec::VERIFICATION.into(), + no_source_retirement_authority: true, + continuous_late_evidence_coverage: false, + }; + let mut receipt_bytes = serde_json::to_vec_pretty(&receipt)?; + receipt_bytes.push(b'\n'); + ensure!( + receipt_bytes.len() <= MAX_RECEIPT_BYTES, + "export_receipt_too_large" + ); + let expected: BTreeSet<_> = receipt.facts.iter().map(|f| f.path.clone()).collect(); + if output.exists() { + directory_files(output, &expected)?; + } else { + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(output)?; + let parent = output + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + File::open(parent)?.sync_all()?; + } + let _lock = output_lock(output)?; + directory_files(output, &expected)?; + if output.join(RECEIPT).exists() { + ensure!( + bounded_file(&output.join(RECEIPT), MAX_RECEIPT_BYTES)? == receipt_bytes, + "existing_export_receipt_conflict" + ); + // A committed receipt cannot be repaired with different or missing facts. + inspect_export(output)?; + return Ok(receipt); + } + for (row, bytes) in receipt.facts.iter().zip(encoded) { + create_immutable(output, &row.path, &bytes)?; + } + create_immutable(output, RECEIPT, &receipt_bytes)?; // Commit last. + Ok(receipt) +} + +pub fn inspect_export(output: &Path) -> Result { + let receipt: ExportReceipt = + serde_json::from_slice(&bounded_file(&output.join(RECEIPT), MAX_RECEIPT_BYTES)?)?; + receipt.execution.validate()?; + ensure!( + receipt.schema == SCHEMA + && receipt.state == "COMPLETE" + && receipt.profile == codec::PROFILE + && receipt.execution_digest_domain == DOMAIN + && receipt.verification == codec::VERIFICATION + && receipt.no_source_retirement_authority + && !receipt.continuous_late_evidence_coverage + && (16..=v2::MAX_FRAME).contains(&receipt.execution_frame_bytes) + && !receipt.facts.is_empty() + && receipt.facts.len() <= MAX_FACTS + && (1..=MAX_SOURCE_BYTES).contains(&receipt.input.bytes) + && (1..=MAX_SOURCE_BYTES).contains(&receipt.input.decoded_bytes) + && v2::is_digest(&receipt.input.sha256) + && v2::is_digest(&receipt.input.decoded_sha256) + && matches!( + receipt.input.kind.as_str(), + "raw-footer-event" + | "raw-evidence-record" + | "source-events-json" + | "source-events-json-zstd" + ), + "invalid_export_receipt" + ); + // Validate every digest and the derived flat filename before joining a + // receipt-controlled path. decode_bound happens after the file is opened. + let mut total_footer_bytes = 0; + for row in &receipt.facts { + row.binding.validate()?; + ensure!( + (1..=codec::MAX_FOOTER_BYTES).contains(&row.footer_bytes), + "invalid_footer_size" + ); + add_footer_bytes(&mut total_footer_bytes, row.footer_bytes)?; + ensure!( + row.path == fact_name(&row.binding), + "invalid_export_fact_path" + ); + } + let expected: BTreeSet<_> = receipt.facts.iter().map(|f| f.path.clone()).collect(); + ensure!( + expected.len() == receipt.facts.len(), + "duplicate_export_fact" + ); + directory_files(output, &expected)?; + let mut footers = Vec::new(); + let mut previous = 0; + let mut bank_footer_sha: Option = None; + for row in &receipt.facts { + ensure!( + receipt.execution.matches(&row.binding) + && row.path == fact_name(&row.binding) + && row.binding.source_event_sequence > previous, + "export_fact_binding_mismatch" + ); + previous = row.binding.source_event_sequence; + let bytes = bounded_file(&output.join(&row.path), codec::MAX_FACT_BYTES)?; + ensure!( + bytes.len() == row.bytes && v2::digest(&bytes) == row.sha256, + "export_fact_digest_mismatch" + ); + let fact = codec::decode_bound(&bytes, &row.binding)?; + ensure!( + fact.footer_wire().len() == row.footer_bytes + && v2::digest(fact.footer_wire()) == row.footer_sha256, + "export_footer_digest_mismatch" + ); + ensure!( + bank_footer_sha + .as_ref() + .is_none_or(|old| old == &row.footer_sha256), + "conflicting_bank_footer" + ); + bank_footer_sha = Some(row.footer_sha256.clone()); + footers.push(serde_json::to_value(fact.footer()?)?); + } + Ok(ExportInspection { + receipt, + local_hashes_consistent: true, + provenance_authenticated: false, + footers, + }) +} + +/// Inspect one bounded fact. Callers that require trusted provenance must use +/// decode_bound with their independent expected binding instead of this view. +pub fn inspect_fact(path: &Path) -> Result { + let bytes = bounded_file(path, codec::MAX_FACT_BYTES)?; + let fact = codec::decode(&bytes)?; + Ok( + serde_json::json!({"profile":codec::PROFILE,"sha256":v2::digest(&bytes), + "bytes":bytes.len(),"binding":fact.binding,"footer":fact.footer()?, + "provenance_authenticated":false,"no_source_retirement_authority":true}), + ) +} + +#[cfg(test)] +#[path = "consensus_tests.rs"] +mod tests; diff --git a/hivezilla/direct-agave/src/consensus_tests.rs b/hivezilla/direct-agave/src/consensus_tests.rs new file mode 100644 index 0000000..ceffb36 --- /dev/null +++ b/hivezilla/direct-agave/src/consensus_tests.rs @@ -0,0 +1,691 @@ +use super::*; +use serde_json::{Value, json}; +use tempfile::TempDir; + +fn source() -> SourceIdentity { + SourceIdentity { + genesis_hash: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d".into(), + node_identity: "J7qpvCEWhdBgPdYRV3s3btWPrRVXa2qnAzgvxWTvtbrQ".into(), + agave_revision: codec::NATIVE_REVISION.into(), + session_id: "fixture-source-session".into(), + } +} +fn bank(id: u64) -> BankIdentity { + BankIdentity { + slot: 460170404, + bank_id: id, + parent_slot: 460170403, + blockhash: [4; 32], + parent_blockhash: [3; 32], + } +} +fn block_frame(source: SourceIdentity, bank: BankIdentity) -> Vec { + let event = v2::BankEvent { + sequence: 7, + source_sequence: 15, + kind: "block".into(), + bank: bank.clone(), + payload_sha256: v2::digest(b"the exact old execution marker"), + payload_frame_sha256: None, + commitment: Some("processed".into()), + confirmation_basis: Some("operator-trusted-agave-processed-bank-callback".into()), + block_height: Some(438202794), + recent_blockhash: None, + observed_at_unix_ms: 1792700777890, + reason: None, + }; + let objects: Vec<_> = v2::REQUIRED + .iter() + .enumerate() + .map(|(offset, name)| v2::Object { + path: (*name).into(), + offset, + bytes: 1, + sha256: v2::digest(&[42]), + }) + .collect(); + let mut marker = serde_json::to_value(&source).unwrap(); + marker["schema"] = "hivezilla-direct-agave-processed-compact-v1".into(); + marker["state"] = "COMPLETE".into(); + marker["source_profile"] = "agave-bank-callbacks-v1".into(); + marker["canonical"] = false.into(); + marker["publication_ready"] = false.into(); + marker["finalized"] = false.into(); + marker["produced_blocks"] = json!([bank]); + marker["files"] = json!( + objects + .iter() + .map(|o| json!({"path":o.path,"bytes":o.bytes,"sha256":o.sha256})) + .collect::>() + ); + v2::encode( + v2::Envelope { + schema: v2::ENVELOPE.into(), + profile: v2::PROFILE.into(), + evidence_included: false, + marker, + objects, + event, + }, + &vec![42; v2::REQUIRED.len()], + ) + .unwrap() + .wire + .to_vec() +} +fn wire() -> Vec { + let mut bytes = vec![1]; // VersionedBlockFooter::V1, no marker wrapper. + bytes.extend_from_slice(&[0x55; 32]); + bytes.extend_from_slice(&1_792_700_777_890_000_123_u64.to_le_bytes()); + bytes.push(3); + bytes.extend_from_slice(&[0xff, 0, 0xfe]); // User agent is not UTF-8. + bytes.extend_from_slice(&[0, 0, 0]); // No optional certificates. + bytes +} +fn raw_footer(id: u64, footer_wire: &[u8]) -> Vec { + serde_json::to_vec( + &json!({"kind":"Footer","slot":460170404,"bank_id":id,"wire_footer":footer_wire}), + ) + .unwrap() +} +fn evidence(sequence: u64, raw: &[u8]) -> Value { + // This fixture returns a Value, which sorts object keys. Hash the exact + // event serialization that source_file() will emit, not its input spelling. + let event: Value = serde_json::from_slice(raw).unwrap(); + let emitted = serde_json::to_vec(&event).unwrap(); + json!({"sequence":sequence,"sha256":v2::digest(&emitted),"event":event}) +} +struct Fixture { + _root: TempDir, + root: PathBuf, + binding: FactBinding, + event: PathBuf, + payload: PathBuf, + output: PathBuf, + raw: Vec, +} +impl Fixture { + fn new() -> Self { + let temp = TempDir::new().unwrap(); + let root = temp.path().to_owned(); + let payload = root.join("payload.bzstrm"); + let frame = block_frame(source(), bank(2403404)); + fs::write(&payload, &frame).unwrap(); + let raw = raw_footer(2403404, &wire()); + let event = root.join("event.json"); + fs::write(&event, &raw).unwrap(); + let binding = FactBinding { + source: source(), + bank: bank(2403404), + execution_payload_sha256: v2::digest(&frame), + source_event_sequence: 3384311167, + source_event_sha256: v2::digest(&raw), + }; + let output = root.join("export"); + Self { + _root: temp, + root, + binding, + event, + payload, + output, + raw, + } + } + fn export(&self) -> Result { + export_event(&self.binding, &self.event, &self.payload, &self.output) + } + fn source_file(&self, rows: &[Value], compressed: bool) -> PathBuf { + let bytes = serde_json::to_vec(rows).unwrap(); + let path = self.root.join(if compressed { + "source-events.json.zst" + } else { + "source-events.json" + }); + fs::write( + &path, + if compressed { + zstd::stream::encode_all(bytes.as_slice(), 1).unwrap() + } else { + bytes + }, + ) + .unwrap(); + path + } +} +fn replace(path: &Path, bytes: &[u8]) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + fs::write(path, bytes).unwrap(); +} + +#[test] +fn consensus_export_binds_actual_frame_and_exact_event_then_replays_without_rewrite() { + let f = Fixture::new(); + let receipt = f.export().unwrap(); + assert_eq!( + receipt.execution_digest_domain, + "exact execution frame bytes" + ); + assert!(receipt.no_source_retirement_authority); + assert!(!receipt.continuous_late_evidence_coverage); + assert_eq!(receipt.verification, "captured_not_verified"); + assert_eq!(receipt.facts[0].binding, f.binding); + assert_eq!(receipt.input.sha256, v2::digest(&f.raw)); + let path = f.output.join(&receipt.facts[0].path); + let fact_bytes = fs::read(&path).unwrap(); + let fact = codec::decode_bound(&fact_bytes, &f.binding).unwrap(); + assert_eq!(fact.footer_wire(), wire()); + let time = fs::metadata(&path).unwrap().modified().unwrap(); + let original_receipt = fs::read(f.output.join(RECEIPT)).unwrap(); + assert_eq!(f.export().unwrap(), receipt); + assert_eq!(fs::read(f.output.join(RECEIPT)).unwrap(), original_receipt); + assert_eq!(fs::metadata(path).unwrap().modified().unwrap(), time); + let inspected = inspect_export(&f.output).unwrap(); + assert!(inspected.local_hashes_consistent); + assert!(!inspected.provenance_authenticated); + assert_eq!( + inspected.footers[0]["block_producer_time_nanos"], + "1792700777890000123" + ); + assert_eq!( + inspected.footers[0]["block_user_agent"], + json!([255, 0, 254]) + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(f.output.join(RECEIPT)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o444 + ); + } +} + +#[test] +fn consensus_export_checks_raw_evidence_sequence_and_original_event_bytes() { + let f = Fixture::new(); + // Pretty printing the outer record must not change the inner event's SHA. + let record = format!( + "{{\n\"sequence\":{},\n\"sha256\":\"{}\",\n\"event\":{}\n}}", + f.binding.source_event_sequence, + f.binding.source_event_sha256, + String::from_utf8(f.raw.clone()).unwrap() + ); + fs::write(&f.event, record).unwrap(); + let receipt = f.export().unwrap(); + assert_eq!(receipt.input.kind, "raw-evidence-record"); + assert_eq!( + receipt.facts[0].binding.source_event_sha256, + v2::digest(&f.raw) + ); + let bad = Fixture::new(); + fs::write( + &bad.event, + serde_json::to_vec(&evidence(bad.binding.source_event_sequence + 1, &bad.raw)).unwrap(), + ) + .unwrap(); + assert!(bad.export().is_err()); + assert!(!bad.output.exists()); +} + +#[test] +fn consensus_export_rejects_payload_digest_source_bank_and_nonframe_before_writes() { + for fault in ["digest", "source", "bank", "nonframe"] { + let mut f = Fixture::new(); + match fault { + "digest" => f.binding.execution_payload_sha256 = "00".repeat(32), + "source" => f.binding.source.session_id = "another-session".into(), + "bank" => f.binding.bank.bank_id += 1, + "nonframe" => { + fs::write(&f.payload, b"small arbitrary file").unwrap(); + f.binding.execution_payload_sha256 = v2::digest(b"small arbitrary file"); + } + _ => unreachable!(), + } + assert!(f.export().is_err(), "{fault}"); + assert!(!f.output.exists(), "{fault}"); + } +} + +#[test] +fn consensus_export_rejects_control_frame_even_with_matching_source_bank_and_sha() { + let mut f = Fixture::new(); + let payload = v2::decode(fs::read(&f.payload).unwrap().into()).unwrap(); + let mut event = payload.envelope.event.clone(); + event.sequence += 1; + event.kind = "invalidate".into(); + event.payload_frame_sha256 = Some(payload.sha256.clone()); + event.commitment = None; + event.confirmation_basis = None; + event.block_height = None; + event.reason = Some("agave_bank_invalidated".into()); + let frame = v2::encode( + v2::Envelope { + schema: v2::ENVELOPE.into(), + profile: v2::PROFILE.into(), + evidence_included: false, + marker: v2::control_marker(&f.binding.source, &event), + objects: vec![], + event, + }, + &[], + ) + .unwrap(); + f.binding.execution_payload_sha256 = frame.sha256; + fs::write(&f.payload, &frame.wire).unwrap(); + assert!(f.export().is_err()); + assert!(!f.output.exists()); +} + +#[test] +fn consensus_source_export_creates_one_bound_fact_per_footer_from_json_and_zstd() { + for compressed in [false, true] { + let f = Fixture::new(); + let raw_status = serde_json::to_vec(&json!({"kind":"Status","slot":460170404,"bank_id":null,"status":"processed","parent":460170403,"dead_error":null})).unwrap(); + let path = f.source_file( + &[ + evidence(100, &raw_status), + evidence(101, &f.raw), + evidence(102, &f.raw), + ], + compressed, + ); + let receipt = export_source_events( + &ExecutionBinding::from(&f.binding), + &path, + &f.payload, + &f.output, + ) + .unwrap(); + assert_eq!(receipt.facts.len(), 2); + assert_eq!(receipt.facts[0].binding.source_event_sequence, 101); + assert_eq!(receipt.facts[1].binding.source_event_sequence, 102); + assert_eq!( + receipt.facts[0].footer_sha256, + receipt.facts[1].footer_sha256 + ); + assert_ne!(receipt.facts[0].sha256, receipt.facts[1].sha256); + assert_eq!(receipt.input.sha256, v2::digest(&fs::read(path).unwrap())); + assert_eq!(inspect_export(&f.output).unwrap().footers.len(), 2); + } +} + +#[test] +fn consensus_source_export_stops_on_conflicting_footer_bytes_for_the_same_bank() { + let f = Fixture::new(); + let mut changed = wire(); + changed[1] ^= 1; // A different, well-formed claimed bank hash. + let other = raw_footer(f.binding.bank.bank_id, &changed); + let path = f.source_file(&[evidence(101, &f.raw), evidence(102, &other)], false); + let error = export_source_events( + &ExecutionBinding::from(&f.binding), + &path, + &f.payload, + &f.output, + ) + .unwrap_err(); + assert_eq!(error.to_string(), "conflicting_bank_footer"); + assert!(!f.output.exists()); +} + +#[test] +fn consensus_export_caps_aggregate_footer_bytes_before_output_or_json_inspection() { + let f = Fixture::new(); + let mut large = vec![1]; + large.extend_from_slice(&[0x55; 32]); + large.extend_from_slice(&1_792_700_777_890_000_123_u64.to_le_bytes()); + large.push(0); // Empty user agent. + large.push(1); // Finalization cert with an opaque 60,000-byte bitmap. + large.extend_from_slice(&460170400_u64.to_le_bytes()); + large.extend_from_slice(&[8; 32]); + large.extend_from_slice(&[0; 96]); + large.extend_from_slice(&60000_u16.to_le_bytes()); + large.extend_from_slice(&vec![0; 60000]); + large.extend_from_slice(&[0, 0, 0]); + codec::decode_footer(&large).unwrap(); + let raw = raw_footer(f.binding.bank.bank_id, &large); + let count = MAX_TOTAL_FOOTER_BYTES / large.len() + 1; + let mut input = Vec::new(); + input.push(b'['); + for sequence in 1..=count { + if sequence > 1 { + input.push(b','); + } + input.extend_from_slice( + format!( + "{{\"sequence\":{sequence},\"sha256\":\"{}\",\"event\":", + v2::digest(&raw) + ) + .as_bytes(), + ); + input.extend_from_slice(&raw); + input.push(b'}'); + } + input.push(b']'); + let path = f.root.join("large-source.json"); + fs::write(&path, input).unwrap(); + let error = export_source_events( + &ExecutionBinding::from(&f.binding), + &path, + &f.payload, + &f.output, + ) + .unwrap_err(); + assert_eq!(error.to_string(), "footer_total_too_large"); + assert!(!f.output.exists()); + + let receipt = f.export().unwrap(); + let mut forged = receipt.clone(); + forged.facts = (1..=count) + .map(|sequence| { + let mut row = receipt.facts[0].clone(); + row.binding.source_event_sequence = sequence as u64; + row.path = fact_name(&row.binding); + row.footer_bytes = large.len(); + row + }) + .collect(); + replace( + &f.output.join(RECEIPT), + &serde_json::to_vec(&forged).unwrap(), + ); + // Fail before reading the nonexistent paths or constructing footer JSON. + assert_eq!( + inspect_export(&f.output).unwrap_err().to_string(), + "footer_total_too_large" + ); +} + +#[test] +fn consensus_source_export_rejects_mixed_banks_bad_hash_order_and_unknown_records() { + for fault in ["bank", "slot", "hash", "order", "duplicate", "unknown"] { + let f = Fixture::new(); + let mut row = evidence(102, &f.raw); + match fault { + "bank" | "slot" | "unknown" => { + let mut event: Value = serde_json::from_slice(&f.raw).unwrap(); + match fault { + "bank" => event["bank_id"] = 1.into(), + "slot" => event["slot"] = 1.into(), + _ => event["kind"] = "UnknownCallback".into(), + } + row = evidence(102, &serde_json::to_vec(&event).unwrap()); + } + "hash" => row["sha256"] = "00".repeat(32).into(), + "order" => row["sequence"] = 100.into(), + "duplicate" => row["sequence"] = 101.into(), + _ => unreachable!(), + } + let path = f.source_file(&[evidence(101, &f.raw), row], false); + assert!( + export_source_events( + &ExecutionBinding::from(&f.binding), + &path, + &f.payload, + &f.output + ) + .is_err(), + "{fault}" + ); + assert!(!f.output.exists()); + } +} + +#[test] +fn consensus_source_export_does_not_claim_absence_is_preactivation() { + let f = Fixture::new(); + let raw = br#"{"kind":"Entry","slot":460170404,"bank_id":2403404}"#; + let path = f.source_file(&[evidence(101, raw)], false); + let error = export_source_events( + &ExecutionBinding::from(&f.binding), + &path, + &f.payload, + &f.output, + ) + .unwrap_err(); + assert_eq!(error.to_string(), "source_events_have_no_footer"); + assert!(!f.output.exists()); +} + +#[test] +fn consensus_export_rejects_event_identity_digest_and_invalid_footer_without_receipt() { + for fault in ["hash", "bank", "footer", "duplicate-key", "oversized"] { + let mut f = Fixture::new(); + match fault { + "hash" => f.binding.source_event_sha256 = "00".repeat(32), + "bank" => { + let raw = raw_footer(42, &wire()); + f.binding.source_event_sha256 = v2::digest(&raw); + fs::write(&f.event, raw).unwrap(); + } + "footer" => { + let raw = raw_footer(f.binding.bank.bank_id, &[1, 2, 3]); + f.binding.source_event_sha256 = v2::digest(&raw); + fs::write(&f.event, raw).unwrap(); + } + "duplicate-key" => { + let raw = String::from_utf8(f.raw.clone()).unwrap().replacen( + "{", + "{\"kind\":\"Footer\",", + 1, + ); + f.binding.source_event_sha256 = v2::digest(raw.as_bytes()); + fs::write(&f.event, raw).unwrap(); + } + "oversized" => { + File::create(&f.event) + .unwrap() + .set_len(MAX_EVENT_BYTES as u64 + 1) + .unwrap(); + } + _ => unreachable!(), + } + assert!(f.export().is_err(), "{fault}"); + assert!(!f.output.exists()); + } +} + +#[test] +fn consensus_export_resumes_after_fact_publication_before_receipt_without_rewriting_fact() { + let f = Fixture::new(); + let receipt = f.export().unwrap(); + let fact_path = f.output.join(&receipt.facts[0].path); + let fact_time = fs::metadata(&fact_path).unwrap().modified().unwrap(); + let original = fs::read(&fact_path).unwrap(); + fs::remove_file(f.output.join(RECEIPT)).unwrap(); + assert_eq!(f.export().unwrap(), receipt); + assert_eq!(fs::read(&fact_path).unwrap(), original); + assert_eq!( + fs::metadata(fact_path).unwrap().modified().unwrap(), + fact_time + ); + assert!(inspect_export(&f.output).is_ok()); +} + +#[test] +fn consensus_export_never_repairs_committed_missing_or_changed_fact_and_never_overwrites() { + for missing in [true, false] { + let f = Fixture::new(); + let receipt = f.export().unwrap(); + let receipt_before = fs::read(f.output.join(RECEIPT)).unwrap(); + let path = f.output.join(&receipt.facts[0].path); + if missing { + fs::remove_file(&path).unwrap(); + } else { + replace(&path, b"corrupt fact bytes"); + } + assert!(f.export().is_err()); + assert!(inspect_export(&f.output).is_err()); + assert_eq!(fs::read(f.output.join(RECEIPT)).unwrap(), receipt_before); + if !missing { + assert_eq!(fs::read(path).unwrap(), b"corrupt fact bytes"); + } else { + assert!(!path.exists()); + } + } +} + +#[test] +fn consensus_export_refuses_different_input_receipt_or_unrelated_output_files() { + let f = Fixture::new(); + f.export().unwrap(); + let receipt_before = fs::read(f.output.join(RECEIPT)).unwrap(); + fs::write( + &f.event, + serde_json::to_vec(&evidence(f.binding.source_event_sequence, &f.raw)).unwrap(), + ) + .unwrap(); + assert!(f.export().is_err()); + assert_eq!(fs::read(f.output.join(RECEIPT)).unwrap(), receipt_before); + let other = Fixture::new(); + fs::create_dir(&other.output).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&other.output, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::write( + other.output.join("DIRECT-AGAVE-COMPACT.v1.json"), + b"old execution marker", + ) + .unwrap(); + assert!(other.export().is_err()); + assert!(!other.output.join(LOCK).exists()); + assert_eq!( + fs::read(other.output.join("DIRECT-AGAVE-COMPACT.v1.json")).unwrap(), + b"old execution marker" + ); +} + +#[test] +fn consensus_export_inspection_rejects_false_authority_and_binding_claims() { + for field in [ + "no_source_retirement_authority", + "continuous_late_evidence_coverage", + "binding", + ] { + let f = Fixture::new(); + f.export().unwrap(); + let mut receipt: Value = + serde_json::from_slice(&fs::read(f.output.join(RECEIPT)).unwrap()).unwrap(); + match field { + "no_source_retirement_authority" => receipt[field] = false.into(), + "continuous_late_evidence_coverage" => receipt[field] = true.into(), + _ => receipt["facts"][0]["binding"]["bank"]["bank_id"] = 42.into(), + } + replace( + &f.output.join(RECEIPT), + &serde_json::to_vec(&receipt).unwrap(), + ); + assert!(inspect_export(&f.output).is_err(), "{field}"); + } +} + +#[test] +fn consensus_export_validates_untrusted_digest_and_flat_path_before_opening_fact() { + for fault in ["digest", "path"] { + let f = Fixture::new(); + f.export().unwrap(); + let mut receipt: Value = + serde_json::from_slice(&fs::read(f.output.join(RECEIPT)).unwrap()).unwrap(); + match fault { + "digest" => { + receipt["facts"][0]["binding"]["source_event_sha256"] = "../outside".into(); + receipt["facts"][0]["path"] = "00000000003384311167-../outside.bzcnf".into(); + } + _ => receipt["facts"][0]["path"] = "../outside.bzcnf".into(), + } + replace( + &f.output.join(RECEIPT), + &serde_json::to_vec(&receipt).unwrap(), + ); + let error = inspect_export(&f.output).unwrap_err().to_string(); + assert!( + matches!( + error.as_str(), + "invalid_consensus_fact_binding" | "invalid_export_fact_path" + ), + "{error}" + ); + } +} + +#[test] +fn consensus_export_relative_output_works_in_a_separate_process() { + let f = Fixture::new(); + fs::write( + f.root.join("binding.json"), + serde_json::to_vec(&f.binding).unwrap(), + ) + .unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "consensus::tests::consensus_relative_output_child", + "--ignored", + ]) + .env("HIVEZILLA_CONSENSUS_TEST_INPUT", &f.root) + .current_dir(&f.root) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(f.root.join("relative-export").join(RECEIPT).is_file()); + assert!(inspect_export(&f.root.join("relative-export")).is_ok()); +} + +#[test] +#[ignore = "subprocess entry point for the relative output regression"] +fn consensus_relative_output_child() { + let root = PathBuf::from( + std::env::var_os("HIVEZILLA_CONSENSUS_TEST_INPUT").expect("subprocess fixture"), + ); + let binding: FactBinding = + serde_json::from_slice(&fs::read(root.join("binding.json")).unwrap()).unwrap(); + export_event( + &binding, + &root.join("event.json"), + &root.join("payload.bzstrm"), + Path::new("relative-export"), + ) + .unwrap(); +} + +#[test] +fn consensus_export_refuses_an_active_output_writer() { + let f = Fixture::new(); + f.export().unwrap(); + let _owner = output_lock(&f.output).unwrap(); + assert!(f.export().is_err()); +} + +#[cfg(unix)] +#[test] +fn consensus_export_rejects_symlink_input_output_and_fact() { + use std::os::unix::fs::symlink; + let f = Fixture::new(); + let event_link = f.root.join("linked-event.json"); + symlink(&f.event, &event_link).unwrap(); + assert!(export_event(&f.binding, &event_link, &f.payload, &f.output).is_err()); + assert!(!f.output.exists()); + symlink(&f.root, &f.output).unwrap(); + assert!(f.export().is_err()); + fs::remove_file(&f.output).unwrap(); + let receipt = f.export().unwrap(); + let fact_path = f.output.join(&receipt.facts[0].path); + fs::remove_file(&fact_path).unwrap(); + symlink(&f.event, fact_path).unwrap(); + assert!(inspect_export(&f.output).is_err()); +} diff --git a/hivezilla/direct-agave/src/lib.rs b/hivezilla/direct-agave/src/lib.rs index 1e13af5..038dfe9 100644 --- a/hivezilla/direct-agave/src/lib.rs +++ b/hivezilla/direct-agave/src/lib.rs @@ -1,5 +1,6 @@ //! Bank-aware Agave execution capture. Queue acceptance is not a durable acknowledgement. pub mod confirmed; +pub mod consensus; pub mod event; pub mod journal; #[cfg(feature = "plugin")] diff --git a/hivezilla/direct-agave/src/main.rs b/hivezilla/direct-agave/src/main.rs index b19d4b5..b4ddbc4 100644 --- a/hivezilla/direct-agave/src/main.rs +++ b/hivezilla/direct-agave/src/main.rs @@ -1,6 +1,9 @@ -use anyhow::Result; +use anyhow::{Result, ensure}; use clap::{Parser, Subcommand}; -use std::path::PathBuf; +use std::{ + io::Read, + path::{Path, PathBuf}, +}; #[derive(Parser)] struct Args { #[command(subcommand)] @@ -8,6 +11,24 @@ struct Args { } #[derive(Subcommand)] enum Command { + /// Export one captured native Footer as a separate immutable consensus fact. + ExportConsensus { + binding: PathBuf, + event: PathBuf, + execution_payload: PathBuf, + output: PathBuf, + }, + /// Export captured footers from one bank's retained source-events JSON or Zstd JSON. + ExportConsensusSourceEvents { + binding: PathBuf, + source_events: PathBuf, + execution_payload: PathBuf, + output: PathBuf, + }, + /// Validate an export receipt and all referenced immutable consensus facts. + InspectConsensusExport { directory: PathBuf }, + /// Read a consensus fact with an independently supplied source/bank/payload binding. + InspectConsensusFact { fact: PathBuf, binding: PathBuf }, /// Build v2 controls and frames from already committed compact banks and native observations. BuildConfirmed { processed: PathBuf, @@ -43,6 +64,59 @@ enum Command { } fn main() -> Result<()> { match Args::parse().command { + Command::ExportConsensus { + binding, + event, + execution_payload, + output, + } => { + let binding = serde_json::from_slice(&read_bounded(&binding, 4096)?)?; + let receipt = hivezilla_direct_agave::consensus::export_event( + &binding, + &event, + &execution_payload, + &output, + )?; + println!("{}", serde_json::to_string(&receipt)?); + Ok(()) + } + Command::ExportConsensusSourceEvents { + binding, + source_events, + execution_payload, + output, + } => { + let binding = serde_json::from_slice(&read_bounded(&binding, 4096)?)?; + let receipt = hivezilla_direct_agave::consensus::export_source_events( + &binding, + &source_events, + &execution_payload, + &output, + )?; + println!("{}", serde_json::to_string(&receipt)?); + Ok(()) + } + Command::InspectConsensusExport { directory } => { + let report = hivezilla_direct_agave::consensus::inspect_export(&directory)?; + println!("{}", serde_json::to_string(&report)?); + Ok(()) + } + Command::InspectConsensusFact { fact, binding } => { + use blockzilla_bank_stream_v2::consensus as c; + let binding = + serde_json::from_slice(&read_bounded(&binding, c::MAX_BINDING_BYTES as u64)?)?; + let bytes = read_bounded(&fact, c::MAX_FACT_BYTES as u64)?; + let value = c::decode_bound(&bytes, &binding)?; + println!( + "{}", + serde_json::json!({ + "profile": c::PROFILE, "binding": value.binding, + "verification": c::VERIFICATION, "carrier_consensus_block_id": null, + "footer": value.footer()?, + }) + ); + Ok(()) + } Command::BuildConfirmed { processed, output, @@ -98,3 +172,12 @@ fn main() -> Result<()> { } } } + +fn read_bounded(path: &Path, limit: u64) -> Result> { + let mut bytes = Vec::new(); + std::fs::File::open(path)? + .take(limit + 1) + .read_to_end(&mut bytes)?; + ensure!(bytes.len() as u64 <= limit, "input_file_too_large"); + Ok(bytes) +} diff --git a/hivezilla/stream-relay/client/README.md b/hivezilla/stream-relay/client/README.md index 6aa619b..389adb8 100644 --- a/hivezilla/stream-relay/client/README.md +++ b/hivezilla/stream-relay/client/README.md @@ -5,6 +5,13 @@ locally. It runs the same Rust decoder in Node.js and browsers through `decoder.wasm`. There are no JavaScript package dependencies or generated Wasm bindings. This package is private and has not been published to npm. +The separate `./consensus` entry point reads immutable retained Alpenglow footer +facts with `decoder-consensus-v1.wasm`. It preserves leader nanoseconds and +certificate target slots as decimal strings. It checks the complete fact digest +and caller-supplied source/bank/execution binding. Certificates remain +`captured_not_verified`; this API does not set a bank's finality state. Read +the [format and delivery plan](../../../docs/architecture/alpenglow-consensus-facts.md). + Start with [Live stream](../../../docs/start/live-stream.md) for connection and transport examples, or [Program dumper](../../../docs/start/program-dumper.md) for a program watch list. The separate [token indexer](../../../docs/start/token-indexer.md) diff --git a/hivezilla/stream-relay/client/consensus-test.mjs b/hivezilla/stream-relay/client/consensus-test.mjs new file mode 100644 index 0000000..5a9e599 --- /dev/null +++ b/hivezilla/stream-relay/client/consensus-test.mjs @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { loadConsensusDecoder, decodeConsensusFact, MAX_CONSENSUS_FACT_BYTES } from './consensus.mjs'; + +const fixtureBase = new URL('../../../crates/blockzilla-bank-stream-v2/tests/fixtures/', import.meta.url); +const footer = await readFile(new URL('captured-footer-3384311167.wincode', fixtureBase)); +const provenance = JSON.parse(await readFile(new URL('captured-footer-3384311167.json', fixtureBase))); +const decoder = await loadConsensusDecoder(); +const sha = bytes => createHash('sha256').update(bytes).digest('hex'); +// This reader test binds retained native bytes to a synthetic execution identity. +// Only the native exporter tests check a matching execution frame. +const binding = { + source: provenance.source, + bank: { slot: provenance.carrier_slot, bank_id: provenance.carrier_bank_id, + blockhash: Array(32).fill(8), parent_slot: provenance.carrier_slot - 1, parent_blockhash: Array(32).fill(7) }, + execution_payload_sha256: 'ab'.repeat(32), + source_event_sequence: provenance.source_event_sequence, + source_event_sha256: provenance.source_event_sha256, +}; +function frame(native = footer) { + const json = Buffer.from(JSON.stringify(binding)), header = Buffer.alloc(20); + header.write('BZCNF001'); header.writeUInt16LE(1, 8); header.writeUInt16LE(1, 10); + header.writeUInt32LE(json.length, 12); header.writeUInt32LE(native.length, 16); + return Buffer.concat([header, json, native]); +} + +test('shared Wasm reader keeps native nanoseconds, bytes and older certificate target', async () => { + assert.equal(sha(footer), provenance.native_footer_sha256); + const bytes = frame(); + const value = (await decodeConsensusFact(bytes, sha(bytes), binding, decoder)).decoded; + assert.equal(value.verification, 'captured_not_verified'); + assert.equal(value.carrier_consensus_block_id, null); + assert.equal(value.footer_sha256, provenance.native_footer_sha256); + assert.equal(value.footer.block_producer_time_nanos, provenance.producer_time_nanos); + assert.equal(BigInt(value.footer.block_producer_time_nanos), 1792700777706079707n); + assert.equal(value.footer.block_final_cert.slot, '460170403'); + assert.notEqual(BigInt(value.footer.block_final_cert.slot), BigInt(binding.bank.slot)); + assert.equal(Buffer.from(value.footer.bank_hash).toString('hex'), provenance.bank_hash_hex); + assert.equal(Buffer.from(value.footer.block_user_agent).toString('hex'), provenance.user_agent_hex); + assert.equal(value.footer.block_final_cert.final_aggregate.signature.length, 96); + assert.equal(value.footer.block_final_cert.notar_aggregate, null); // Captured fast-final path. + assert.equal(value.footer.skip_reward_cert, null); + assert.equal(value.footer.notar_reward_cert.aggregate.signature.length, 96); +}); + +test('SDK rejects altered bytes and a different source, bank, payload or event binding', async () => { + const wire = frame(); + await assert.rejects(decodeConsensusFact(wire, '00'.repeat(32), binding, decoder), /consensus_digest_mismatch/); + const alternatives = [ + b => { b.source.session_id = 'different-session'; }, + b => { b.bank.bank_id++; }, + b => { b.bank.parent_blockhash[0] ^= 1; }, + b => { b.execution_payload_sha256 = '12'.repeat(32); }, + b => { b.source_event_sequence++; }, + b => { b.source_event_sha256 = '34'.repeat(32); }, + ]; + for (const alter of alternatives) { + const other = structuredClone(binding); alter(other); + await assert.rejects(decodeConsensusFact(wire, sha(wire), other, decoder), /consensus_decode_failed/); + } +}); + +test('SDK keeps Buffer bytes private while the digest is pending', async () => { + const wire = frame(), original = Buffer.from(wire), expectedDigest = sha(original); + const pending = decodeConsensusFact(wire, expectedDigest, binding, decoder); + // Change a valid footer field after hashing starts and before decoding resumes. + wire[wire.length - footer.length + 1] ^= 1; + assert.notEqual(sha(wire), expectedDigest); + const result = await pending; + assert.equal(result.digest, expectedDigest); + assert.equal(sha(result.wire), expectedDigest); + assert.deepEqual(Buffer.from(result.wire), original); + assert.equal(result.decoded.footer_sha256, provenance.native_footer_sha256); + assert.equal(Buffer.from(result.decoded.footer.bank_hash).toString('hex'), provenance.bank_hash_hex); +}); + +test('shared parser rejects an unknown grammar, invalid option and every truncated footer', async () => { + const good = frame(); + for (const at of [0, 8, 10]) { + const bad = Buffer.from(good); bad[at] ^= 2; + await assert.rejects(decodeConsensusFact(bad, sha(bad), binding, decoder), /consensus_decode_failed/); + } + const native = Buffer.from(footer), optionAt = 42 + native[41]; + native[optionAt] = 2; + const bad = frame(native); + await assert.rejects(decodeConsensusFact(bad, sha(bad), binding, decoder), /consensus_decode_failed/); + for (let n = 0; n < footer.length; n++) { + assert.throws(() => decoder.decode(frame(footer.subarray(0, n)), binding), /consensus_decode_failed/); + } + assert.throws(() => decoder.decode(new Uint8Array(MAX_CONSENSUS_FACT_BYTES + 1), binding), /invalid_consensus_fact_size/); +}); + +test('full u64 time and absent certificates stay exact without a Tower fallback', () => { + const native = Buffer.alloc(45); native[0] = 1; native.writeBigUInt64LE(0xffffffffffffffffn, 33); + const value = decoder.decode(frame(native), binding); + assert.equal(value.footer.block_producer_time_nanos, '18446744073709551615'); + assert.equal(value.footer.block_final_cert, null); + assert.equal(value.footer.skip_reward_cert, null); + assert.equal(value.footer.notar_reward_cert, null); + assert.equal(value.verification, 'captured_not_verified'); + const before = decoder.memoryBytes; + for (let n = 0; n < 200; n++) decoder.decode(frame(), binding); + assert.ok(decoder.memoryBytes <= before + 4 * 65536, 'owned Wasm buffers must be released'); +}); diff --git a/hivezilla/stream-relay/client/consensus.d.mts b/hivezilla/stream-relay/client/consensus.d.mts new file mode 100644 index 0000000..f9e29a2 --- /dev/null +++ b/hivezilla/stream-relay/client/consensus.d.mts @@ -0,0 +1,39 @@ +export const CONSENSUS_PROFILE: 'native-consensus-fact-v1'; +export const MAX_CONSENSUS_FACT_BYTES: number; +export interface ConsensusBinding { + source: { genesis_hash: string; node_identity: string; agave_revision: string; session_id: string }; + bank: { slot: number; bank_id: number; blockhash: number[]; parent_slot: number; parent_blockhash: number[] }; + execution_payload_sha256: string; + source_event_sequence: number; + source_event_sha256: string; +} +export interface CapturedAggregate { signature: number[]; bitmap: number[] } +export interface ConsensusProjection { + profile: 'native-consensus-fact-v1'; + binding: ConsensusBinding; + verification: 'captured_not_verified'; + carrier_consensus_block_id: null; + footer_bytes: number; + footer_sha256: string; + footer: { + native_schema: 'agave-4.3.2-versioned-block-footer-v1'; + verification: 'captured_not_verified'; + bank_hash: number[]; + /** Exact Unix nanoseconds. Use BigInt(value) for arithmetic. */ + block_producer_time_nanos: string; + block_user_agent: number[]; + block_final_cert: null | { + slot: string; block_id: number[]; + final_aggregate: CapturedAggregate; notar_aggregate: CapturedAggregate | null; + }; + skip_reward_cert: null | { slot: string; aggregate: CapturedAggregate }; + notar_reward_cert: null | { slot: string; block_id: number[]; aggregate: CapturedAggregate }; + }; +} +export interface ConsensusDecoder { + decode(wire: Uint8Array, binding: ConsensusBinding): ConsensusProjection; + readonly memoryBytes: number; +} +export function loadConsensusDecoder(wasm?: URL | string | BufferSource | WebAssembly.Module): Promise; +export function decodeConsensusFact(wire: Uint8Array, expectedDigest: string, binding: ConsensusBinding, + decoder: ConsensusDecoder): Promise<{ wire: Uint8Array; digest: string; decoded: ConsensusProjection }>; diff --git a/hivezilla/stream-relay/client/consensus.mjs b/hivezilla/stream-relay/client/consensus.mjs new file mode 100644 index 0000000..15229e8 --- /dev/null +++ b/hivezilla/stream-relay/client/consensus.mjs @@ -0,0 +1,71 @@ +// Independent immutable footer facts. Existing v1/v2 execution readers stay separate. +import { StreamError } from './index.mjs'; + +export const CONSENSUS_PROFILE = 'native-consensus-fact-v1'; +export const MAX_CONSENSUS_FACT_BYTES = 20 + 4096 + 65535; +const MAX_BINDING_BYTES = 4096, MAX_OUTPUT_BYTES = 1024 * 1024; +const encoder = new TextEncoder(), text = new TextDecoder('utf-8', { fatal: true }); +const check = (ok, code, detail) => { if (!ok) throw new StreamError(code, detail); }; +const sha = async bytes => [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))] + .map(n => n.toString(16).padStart(2, '0')).join(''); + +/** Use the same strict Rust footer grammar in Node.js and the browser. */ +export async function loadConsensusDecoder(wasm = new URL('./decoder-consensus-v1.wasm', import.meta.url)) { + if (wasm instanceof URL || typeof wasm === 'string') { + const url = new URL(wasm, import.meta.url); + if (url.protocol === 'file:' && typeof process !== 'undefined' && process.versions?.node) { + wasm = await (await import('node:fs/promises')).readFile(url); + } else { + const response = await fetch(url, { redirect: 'error' }); + check(response.ok, 'decoder_download_failed'); + wasm = await response.arrayBuffer(); + } + } + const result = await WebAssembly.instantiate(wasm, {}); + const api = (result instanceof WebAssembly.Instance ? result : result.instance).exports; + check(api.memory instanceof WebAssembly.Memory + && ['bz_alloc', 'bz_free', 'bz_decode_consensus'].every(k => typeof api[k] === 'function'), + 'invalid_consensus_decoder_abi'); + return { + decode(wire, binding) { + check(wire instanceof Uint8Array && wire.length >= 20 && wire.length <= MAX_CONSENSUS_FACT_BYTES, + 'invalid_consensus_fact_size'); + const options = encoder.encode(JSON.stringify(binding)); + check(options.length <= MAX_BINDING_BYTES, 'consensus_binding_too_large'); + let input = 0, opts = 0, output = 0, n = 0; + try { + input = api.bz_alloc(wire.length); + new Uint8Array(api.memory.buffer, input, wire.length).set(wire); + opts = api.bz_alloc(options.length); + new Uint8Array(api.memory.buffer, opts, options.length).set(options); + const packed = api.bz_decode_consensus(input, wire.length, opts, options.length); + output = Number(packed & 0xffffffffn); n = Number(packed >> 32n); + check(n > 0 && n <= MAX_OUTPUT_BYTES, 'consensus_output_limit_exceeded'); + const value = JSON.parse(text.decode(new Uint8Array(api.memory.buffer, output, n))); + check(!value.error, 'consensus_decode_failed', { reason: value.error }); + check(value.profile === CONSENSUS_PROFILE && value.verification === 'captured_not_verified' + && value.carrier_consensus_block_id === null, 'invalid_consensus_projection'); + return value; + } finally { + if (output) api.bz_free(output, n); + if (opts) api.bz_free(opts, options.length); + if (input) api.bz_free(input, wire.length); + } + }, + get memoryBytes() { return api.memory.buffer.byteLength; }, + }; +} + +/** Bind the whole fact to a trusted receipt and its execution payload. The + * decoded certificate target can be older than the carrying block. This call + * does not set a bank's confirmed/rooted state or verify BLS signatures. */ +export async function decodeConsensusFact(wire, expectedDigest, binding, decoder) { + check(wire instanceof Uint8Array && wire.length >= 20 && wire.length <= MAX_CONSENSUS_FACT_BYTES, + 'invalid_consensus_fact_size'); + check(typeof expectedDigest === 'string' && /^[a-f0-9]{64}$/.test(expectedDigest), 'invalid_consensus_digest'); + const copy = new Uint8Array(wire), expected = structuredClone(binding); + const digest = await sha(copy); + check(digest === expectedDigest, 'consensus_digest_mismatch'); + const decoded = decoder.decode(copy, expected); + return { wire: copy, digest, decoded }; +} diff --git a/hivezilla/stream-relay/client/decoder-consensus-v1.wasm b/hivezilla/stream-relay/client/decoder-consensus-v1.wasm new file mode 100644 index 0000000..6ed9b46 Binary files /dev/null and b/hivezilla/stream-relay/client/decoder-consensus-v1.wasm differ diff --git a/hivezilla/stream-relay/client/package.json b/hivezilla/stream-relay/client/package.json index 952eeb9..4f410e4 100644 --- a/hivezilla/stream-relay/client/package.json +++ b/hivezilla/stream-relay/client/package.json @@ -12,10 +12,14 @@ "./banks": { "types": "./banks.d.mts", "default": "./banks.mjs" + }, + "./consensus": { + "types": "./consensus.d.mts", + "default": "./consensus.mjs" } }, "scripts": { - "test": "node --test test.mjs sdk-test.mjs banks-test.mjs" + "test": "node --test test.mjs sdk-test.mjs banks-test.mjs consensus-test.mjs" }, "engines": { "node": ">=22" diff --git a/hivezilla/stream-relay/decoder/README.md b/hivezilla/stream-relay/decoder/README.md index ff44f63..5b89b9f 100644 --- a/hivezilla/stream-relay/decoder/README.md +++ b/hivezilla/stream-relay/decoder/README.md @@ -30,3 +30,16 @@ The supported result is semantic compact transaction data. Exact signed wire reconstruction, full RPC rendering, portable log expansion, and a shared stable live dictionary are separate work. Do not present this projection as canonical archive data or interpret absent inner metadata as proved absence of CPI calls. + +## Native consensus facts + +The separate `bz_decode_consensus` export reads `native-consensus-fact-v1` +through the shared Rust codec. Its configuration is the full expected +`FactBinding`, supplied independently by the caller. It returns exact leader +nanoseconds as a decimal string. Captured certificate targets remain separate +from the carrying block. This reader does not verify BLS signatures or change +the existing execution decoder's admission rules. + +Copy the Wasm output to `../client/decoder-consensus-v1.wasm` for the opt-in +`consensus.mjs` reader. Keep the existing v1/v2 decoder files separate. See the +[format and delivery plan](../../../docs/architecture/alpenglow-consensus-facts.md). diff --git a/hivezilla/stream-relay/decoder/src/consensus.rs b/hivezilla/stream-relay/decoder/src/consensus.rs new file mode 100644 index 0000000..d4f77c7 --- /dev/null +++ b/hivezilla/stream-relay/decoder/src/consensus.rs @@ -0,0 +1,42 @@ +//! Read the independent native consensus fact profile with the shared parser. +//! A bound captured footer is an observation, not a verified BLS certificate. +use anyhow::{Result, ensure}; +use blockzilla_bank_stream_v2::{consensus as fact, digest}; + +/// The caller must supply the exact binding from its trusted execution/evidence +/// receipt. In particular, a footer must not select its own execution dependency. +pub fn decode(wire: &[u8], expected: &fact::FactBinding) -> Result> { + let value = fact::decode_bound(wire, expected)?; + let output = serde_json::to_vec(&serde_json::json!({ + "profile": fact::PROFILE, + "binding": value.binding, + "verification": fact::VERIFICATION, + "carrier_consensus_block_id": null, + "footer_bytes": value.footer_wire().len(), + "footer_sha256": digest(value.footer_wire()), + "footer": value.footer()?, + }))?; + ensure!(output.len() <= 1_048_576, "consensus_output_limit_exceeded"); + Ok(output) +} + +#[cfg(target_arch = "wasm32")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bz_decode_consensus( + ptr: u32, + len: u32, + binding_ptr: u32, + binding_len: u32, +) -> u64 { + super::bank_output((|| { + ensure!( + len as usize <= fact::MAX_FACT_BYTES && binding_len as usize <= fact::MAX_BINDING_BYTES, + "consensus_input_limit_exceeded" + ); + let wire = unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize) }; + let expected: fact::FactBinding = serde_json::from_slice(unsafe { + std::slice::from_raw_parts(binding_ptr as *const u8, binding_len as usize) + })?; + decode(wire, &expected) + })()) +} diff --git a/hivezilla/stream-relay/decoder/src/lib.rs b/hivezilla/stream-relay/decoder/src/lib.rs index 36281b8..daac0b6 100644 --- a/hivezilla/stream-relay/decoder/src/lib.rs +++ b/hivezilla/stream-relay/decoder/src/lib.rs @@ -13,6 +13,8 @@ use std::{ io::{Read, Write}, }; +pub mod consensus; + const MAX_FRAME: usize = 16 * 1024 * 1024 + 65_536 + 16; const MAX_BLOCK: usize = 64 * 1024 * 1024; const MAX_OUTPUT: usize = 32 * 1024 * 1024;