diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml index 30e0dffc42..b5805d903e 100644 --- a/.github/workflows/tests-unit.yml +++ b/.github/workflows/tests-unit.yml @@ -347,7 +347,7 @@ jobs: id-token: write statuses: write runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 with: @@ -358,8 +358,10 @@ jobs: - uses: ./.github/actions/setup-zakura-build - name: Test experimental NU7 builds run: | + cargo test --locked --features zakura-chain/nu7-experimental \ + -p zakura-chain -p zakura-header-chain --lib cargo test --locked --features nu7-experimental \ - -p zakura-consensus --lib + -p zakura-consensus -p zakura-rpc --lib cargo check --locked -p zakura --features nu7-experimental network-dependent: diff --git a/Cargo.lock b/Cargo.lock index b6ca6556d2..a0faff11b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7436,7 +7436,7 @@ dependencies = [ [[package]] name = "zakura-chain" -version = "7.0.0" +version = "7.0.1" dependencies = [ "bech32", "bitflags", @@ -7505,7 +7505,7 @@ dependencies = [ [[package]] name = "zakura-consensus" -version = "8.0.1" +version = "9.0.0" dependencies = [ "blake2b_simd", "chrono", @@ -7605,7 +7605,7 @@ dependencies = [ [[package]] name = "zakura-header-chain" -version = "2.1.0" +version = "2.1.1" dependencies = [ "chrono", "criterion", @@ -8147,7 +8147,7 @@ dependencies = [ [[package]] name = "zakura-state" -version = "8.0.0" +version = "8.0.1" dependencies = [ "bincode", "chrono", diff --git a/crates/zakura-chain/Cargo.toml b/crates/zakura-chain/Cargo.toml index 0a11b3b71b..044a64edce 100644 --- a/crates/zakura-chain/Cargo.toml +++ b/crates/zakura-chain/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zakura-chain" -version = "7.0.0" +version = "7.0.1" authors.workspace = true description = "Core Zcash data structures for the Zakura node. Internal crate, published to support cargo install zakura" license.workspace = true @@ -20,6 +20,15 @@ default = [] # Production features that activate extra functionality +# Experimental candidate NU7 consensus rules, currently including ZIP 218's +# shorter target spacing, wider difficulty averaging window, reduced subsidy by +# the spacing ratio, and per-block shielded action limits. +# +# The rules stay dormant until NU7 has an activation height on the configured +# network, so this feature only changes consensus on a network that configures +# one. +nu7-experimental = [] + # Consensus-critical conversion from JSON to Zcash types json-conversion = [ "serde_json", diff --git a/crates/zakura-chain/src/parameters/network.rs b/crates/zakura-chain/src/parameters/network.rs index 38bfb1ca4c..12a251d940 100644 --- a/crates/zakura-chain/src/parameters/network.rs +++ b/crates/zakura-chain/src/parameters/network.rs @@ -342,6 +342,27 @@ impl Network { .collect() } + /// Returns the first height at which version 4 transactions are invalid. + /// + /// [ZIP 2003] deprecates version 4 transactions at NU7. The + /// `nu7-experimental` feature must be enabled. A network that does not + /// activate NU7 keeps accepting them. + /// + /// [ZIP 2003]: https://zips.z.cash/zip-2003 + pub fn v4_deprecation_height(&self) -> Option { + if cfg!(feature = "nu7-experimental") { + NetworkUpgrade::Nu7.activation_height(self) + } else { + None + } + } + + /// Returns whether version 4 transactions are invalid at `height` on this network. + pub fn is_v4_deprecated(&self, height: Height) -> bool { + self.v4_deprecation_height() + .is_some_and(|deprecation_height| height >= deprecation_height) + } + /// Returns the height at which the soft fork that temporarily disables Orchard /// actions in transactions activates, if it is configured for this network. pub fn temporary_orchard_disabling_soft_fork_height(&self) -> Option { diff --git a/crates/zakura-chain/src/parameters/network/subsidy.rs b/crates/zakura-chain/src/parameters/network/subsidy.rs index 20e82e95fb..0c9a5c2528 100644 --- a/crates/zakura-chain/src/parameters/network/subsidy.rs +++ b/crates/zakura-chain/src/parameters/network/subsidy.rs @@ -279,24 +279,19 @@ impl ParameterSubsidy for Network { /// as described in [protocol specification §7.10][7.10] /// /// [7.10]: https://zips.z.cash/protocol/protocol.pdf#fundingstreams -pub fn funding_stream_address_period(height: Height, network: &N) -> u32 { +pub fn funding_stream_address_period( + height: Height, + network: &N, +) -> HeightDiff { // Spec equation: `address_period = floor((height - (height_for_halving(1) - post_blossom_halving_interval))/funding_stream_address_change_interval)`, // // // Note that the brackets make it so the post blossom halving interval is added to the total. // - // In Rust, "integer division rounds towards zero": - // - // This is the same as `floor()`, because these numbers are all positive. - let height_after_first_halving = height - network.height_for_first_halving(); - let address_period = (height_after_first_halving + network.post_blossom_halving_interval()) - / network.funding_stream_address_change_interval(); - - address_period - .try_into() - .expect("all values are positive and smaller than the input height") + (height_after_first_halving + network.post_blossom_halving_interval()) + .div_euclid(network.funding_stream_address_change_interval()) } /// The first block height of the halving at the provided halving index for a network. @@ -309,26 +304,24 @@ pub fn height_for_halving(halving: u32, network: &Network) -> Option { return Some(Height(0)); } - let slow_start_shift = i64::from(network.slow_start_shift().0); - let blossom_height = i64::from(NetworkUpgrade::Blossom.activation_height(network)?.0); - let pre_blossom_halving_interval = network.pre_blossom_halving_interval(); - let halving_index = i64::from(halving); - - let unscaled_height = halving_index.checked_mul(pre_blossom_halving_interval)?; - - let pre_blossom_height = unscaled_height - .min(blossom_height) - .checked_add(slow_start_shift)?; - - let post_blossom_height = 0 - .max(unscaled_height - blossom_height) - .checked_mul(i64::from(BLOSSOM_POW_TARGET_SPACING_RATIO))? - .checked_add(slow_start_shift)?; + if self::halving(Height::MAX, network) < halving { + return None; + } - let height = pre_blossom_height.checked_add(post_blossom_height)?; + // `halving` is monotonic. Search its complete height domain so this inverse + // automatically includes every target-spacing era. + let mut low = Height::MIN.0; + let mut high = Height::MAX.0; + while low < high { + let middle = low + (high - low) / 2; + if self::halving(Height(middle), network) < halving { + low = middle + 1; + } else { + high = middle; + } + } - let height = u32::try_from(height).ok()?; - height.try_into().ok() + Some(Height(low)) } /// Returns the `fs.Value(height)` for each stream receiver @@ -417,28 +410,38 @@ pub fn halving_divisor(height: Height, network: &Network) -> Option { /// [7.8]: https://zips.z.cash/protocol/protocol.pdf#subsidies pub fn halving(height: Height, network: &Network) -> u32 { let slow_start_shift = network.slow_start_shift(); - let blossom_height = NetworkUpgrade::Blossom - .activation_height(network) - .expect("blossom activation height should be available"); - - let halving_index = if height < slow_start_shift { - 0 - } else if height < blossom_height { - let pre_blossom_height = height - slow_start_shift; - pre_blossom_height / network.pre_blossom_halving_interval() - } else { - let pre_blossom_height = blossom_height - slow_start_shift; - let scaled_pre_blossom_height = - pre_blossom_height * HeightDiff::from(BLOSSOM_POW_TARGET_SPACING_RATIO); + if height < slow_start_shift { + return 0; + } - let post_blossom_height = height - blossom_height; + // Each target spacing era contributes (blocks in the era * era spacing) to a + // running total of block seconds, which the pre-Blossom halving interval + // measured in seconds then divides. This is the spec's segmented sum of + // fractions with the common denominator factored out, so it stays in integer + // arithmetic no matter how many spacing eras a network has. ZIP 218 adds a + // third era at NU7. + let pre_blossom_spacing_seconds = NetworkUpgrade::Genesis.target_spacing().num_seconds(); + let mut total_block_seconds: HeightDiff = 0; + + let mut eras = NetworkUpgrade::target_spacings(network) + .filter(|(era_start, _)| *era_start <= height) + .peekable(); + + while let Some((era_start, era_spacing)) = eras.next() { + let era_end = eras + .peek() + .map(|(next_start, _)| *next_start) + .unwrap_or(height); + let era_blocks = (era_end - era_start.max(slow_start_shift)).max(0); + total_block_seconds += era_blocks * era_spacing.num_seconds(); + } - (scaled_pre_blossom_height + post_blossom_height) / network.post_blossom_halving_interval() - }; + let pre_blossom_denominator = + network.pre_blossom_halving_interval() * pre_blossom_spacing_seconds; - halving_index + (total_block_seconds / pre_blossom_denominator) .try_into() - .expect("already checked for negatives") + .expect("halving index is non-negative and fits in u32") } /// `BlockSubsidy(height)` as described in [protocol specification §7.8][7.8] @@ -462,13 +465,17 @@ pub fn block_subsidy(height: Height, net: &Network) -> Result, network: &Network, ) -> usize { - 1u32.checked_add(funding_stream_address_period( + 1i64.checked_add(funding_stream_address_period( height_range .end .previous() @@ -324,7 +324,9 @@ fn num_funding_stream_addresses_required_for_height_range( )) .expect("no overflow should happen in this sum") .checked_sub(funding_stream_address_period(height_range.start, network)) - .expect("no overflow should happen in this sub") as usize + .expect("no overflow should happen in this sub") + .try_into() + .expect("a funding stream height range must not have a negative number of periods") } /// Checks that the provided [`FundingStreams`] has sufficient recipient addresses for the diff --git a/crates/zakura-chain/src/parameters/network/tests.rs b/crates/zakura-chain/src/parameters/network/tests.rs index a12c47d11a..757ed0a0be 100644 --- a/crates/zakura-chain/src/parameters/network/tests.rs +++ b/crates/zakura-chain/src/parameters/network/tests.rs @@ -318,3 +318,178 @@ fn check_height_for_num_halvings() { } } } + +/// Tests the ZIP 218 target spacing, halving, and block subsidy across the NU7 +/// activation boundary on a configured Testnet. +#[test] +#[cfg(feature = "nu7-experimental")] +fn post_nu7_spacing_halving_and_subsidy() -> Result<(), Report> { + use crate::parameters::{ + testnet::{self, ConfiguredActivationHeights}, + NU7_POW_TARGET_SPACING_RATIO, POST_BLOSSOM_POW_TARGET_SPACING, POST_NU7_POW_TARGET_SPACING, + }; + + let _init_guard = zakura_test::init(); + + // Choose parameters where slow_start_shift == blossom_height, so the + // pre-Blossom term of the spec's halving sum is exactly zero and the halving + // boundaries land on multiples of the post-Blossom halving interval. + let blossom = 1u32; + let canopy = blossom + u32::try_from(POST_BLOSSOM_HALVING_INTERVAL).unwrap(); + let nu7 = canopy + u32::try_from(POST_BLOSSOM_HALVING_INTERVAL * 2).unwrap(); + + let network = testnet::Parameters::build() + // slow_start_shift = slow_start_interval / 2 = 1, the Blossom height. + .with_slow_start_interval(Height(2)) + .with_activation_heights(ConfiguredActivationHeights { + blossom: Some(blossom), + canopy: Some(canopy), + nu7: Some(nu7), + ..Default::default() + }) + .expect("activation heights are valid") + .clear_funding_streams() + .to_network() + .expect("configured testnet is valid"); + + let nu7_height = Height(nu7); + + // The target spacing shortens exactly at the NU7 activation height. + assert_eq!( + i64::from(POST_BLOSSOM_POW_TARGET_SPACING), + NetworkUpgrade::target_spacing_for_height(&network, (nu7_height - 1).unwrap()) + .num_seconds() + ); + assert_eq!( + i64::from(POST_NU7_POW_TARGET_SPACING), + NetworkUpgrade::target_spacing_for_height(&network, nu7_height).num_seconds() + ); + + // Three post-Blossom halvings have elapsed at NU7 activation: + // Halving = floor(0/PreBlossom + 1 + 2) = 3 + assert_eq!(3, halving(nu7_height, &network)); + assert_eq!(8, halving_divisor(nu7_height, &network).unwrap()); + + // BlockSubsidy(NU7) = floor(MAX / (BlossomRatio * NU7Ratio * 2^Halving)) + // = floor(1_250_000_000 / (2 * 3 * 8)) = 26_041_666 zatoshi + assert_eq!( + Amount::::try_from(26_041_666)?, + block_subsidy(nu7_height, &network)?, + ); + + // The third halving boundary lands exactly at NU7 here, so the block before + // NU7 is still in halving era 2: floor(1_250_000_000 / (2 * 4)) zatoshi. + assert_eq!(2, halving((nu7_height - 1).unwrap(), &network)); + assert_eq!( + Amount::::try_from(156_250_000)?, + block_subsidy((nu7_height - 1).unwrap(), &network)?, + ); + + // The halving counter does not reset at NU7. The next boundary arrives after + // one PostNU7HalvingInterval (= PostBlossomHalvingInterval * 3) of blocks. + let post_nu7_halving_interval = + POST_BLOSSOM_HALVING_INTERVAL * i64::from(NU7_POW_TARGET_SPACING_RATIO); + let next_halving = (nu7_height + post_nu7_halving_interval).unwrap(); + assert_eq!(4, halving(next_halving, &network)); + assert_eq!(Some(next_halving), height_for_halving(4, &network)); + assert_eq!( + 3, + halving( + height_for_halving(4, &network) + .expect("the fourth halving has a height") + .previous() + .expect("the fourth halving is above genesis"), + &network, + ) + ); + assert_eq!(16, halving_divisor(next_halving, &network).unwrap()); + assert_eq!( + Amount::::try_from(13_020_833)?, + block_subsidy(next_halving, &network)?, + ); + + Ok(()) +} + +/// Tests funding stream periods before the first period anchor on a configured Testnet. +#[test] +#[cfg(feature = "nu7-experimental")] +fn funding_stream_period_before_anchor_uses_floor_division() -> Result<(), Report> { + use crate::parameters::{ + subsidy::funding_stream_address_period, + testnet::{self, ConfiguredActivationHeights}, + }; + + let _init_guard = zakura_test::init(); + + let network = testnet::Parameters::build() + .with_activation_heights(ConfiguredActivationHeights { + blossom: Some(4), + canopy: Some(6), + nu7: Some(11), + ..Default::default() + }) + .expect("activation heights are valid") + .clear_funding_streams() + .to_network() + .expect("configured testnet is valid"); + + let first_period_height = (network.height_for_first_halving() + - network.post_blossom_halving_interval()) + .expect("the first period starts above genesis"); + + assert_eq!( + 0, + funding_stream_address_period(first_period_height, &network) + ); + assert_eq!( + -1, + funding_stream_address_period( + first_period_height + .previous() + .expect("the height before the first period exists"), + &network, + ) + ); + + Ok(()) +} + +/// Tests that the ZIP 218 difficulty averaging window widens at the NU7 +/// activation height. +#[test] +#[cfg(feature = "nu7-experimental")] +fn averaging_window_changes_at_nu7_activation_height() -> Result<(), Report> { + use crate::parameters::{ + testnet::{self, ConfiguredActivationHeights}, + POST_NU7_POW_AVERAGING_WINDOW, PRE_NU7_POW_AVERAGING_WINDOW, + }; + + let _init_guard = zakura_test::init(); + + let network = testnet::Parameters::build() + .with_activation_heights(ConfiguredActivationHeights { + blossom: Some(1), + nu7: Some(10), + ..Default::default() + }) + .expect("activation heights are valid") + .clear_funding_streams() + .to_network() + .expect("configured testnet is valid"); + + assert_eq!( + PRE_NU7_POW_AVERAGING_WINDOW, + NetworkUpgrade::averaging_window_for_height(&network, Height(9)) + ); + assert_eq!( + POST_NU7_POW_AVERAGING_WINDOW, + NetworkUpgrade::averaging_window_for_height(&network, Height(10)) + ); + assert_eq!( + POST_NU7_POW_AVERAGING_WINDOW, + NetworkUpgrade::averaging_window_for_height(&network, Height(11)) + ); + + Ok(()) +} diff --git a/crates/zakura-chain/src/parameters/network_upgrade.rs b/crates/zakura-chain/src/parameters/network_upgrade.rs index 8fb7601f3b..6e26389ddb 100644 --- a/crates/zakura-chain/src/parameters/network_upgrade.rs +++ b/crates/zakura-chain/src/parameters/network_upgrade.rs @@ -251,10 +251,84 @@ const PRE_BLOSSOM_POW_TARGET_SPACING: i64 = 150; /// The target block spacing after Blossom activation. pub const POST_BLOSSOM_POW_TARGET_SPACING: u32 = 75; +/// Whether the ZIP 218 consensus rules are compiled into this build. +/// +/// ZIP 218 lowers the block target spacing from 75 to 25 seconds at NU7, widens +/// the difficulty averaging window to match, divides the block subsidy by the +/// spacing ratio, and adds per-block shielded action limits. The rules are still +/// dormant until NU7 activates on the configured network, so a build with this +/// feature enabled follows today's consensus on any network without an NU7 +/// activation height. +/// +/// Enabled by the `nu7-experimental` feature. +pub const ZIP218_ENABLED: bool = cfg!(feature = "nu7-experimental"); + +/// The target block spacing after NU7 activation, in seconds. +/// +/// `PostNU7PoWTargetSpacing` in ZIP 218. +pub const POST_NU7_POW_TARGET_SPACING: u32 = 25; + +/// The ratio between the post-Blossom and post-NU7 block target spacings. +/// +/// `NU7PoWTargetSpacingRatio` in ZIP 218: +/// `PostBlossomPoWTargetSpacing / PostNU7PoWTargetSpacing = 75 / 25 = 3`. +pub const NU7_POW_TARGET_SPACING_RATIO: u32 = + POST_BLOSSOM_POW_TARGET_SPACING / POST_NU7_POW_TARGET_SPACING; + /// The averaging window for difficulty threshold arithmetic mean calculations. /// +/// `PoWAveragingWindow` in the Zcash specification. ZIP 218 makes this window +/// height-dependent, so prefer [`NetworkUpgrade::averaging_window`] or +/// [`NetworkUpgrade::averaging_window_for_height`] over this constant. +pub const POW_AVERAGING_WINDOW: usize = PRE_NU7_POW_AVERAGING_WINDOW; + +/// The averaging window for difficulty threshold arithmetic mean calculations +/// before NU7. +/// /// `PoWAveragingWindow` in the Zcash specification. -pub const POW_AVERAGING_WINDOW: usize = 17; +pub const PRE_NU7_POW_AVERAGING_WINDOW: usize = 17; + +/// The averaging window for difficulty threshold arithmetic mean calculations +/// from NU7 onwards. +/// +/// `PostNU7PoWAveragingWindow` in ZIP 218. The window covers the same wall-clock +/// timespan at 25 second spacing that 17 blocks covered at the launch 150 second +/// spacing: `17 * (150 / 25) = 102` blocks. +pub const POST_NU7_POW_AVERAGING_WINDOW: usize = 102; + +/// Per-block limit on the total number of Orchard actions, applied from NU7 +/// activation onwards. +/// +/// `OrchardBlockActionLimit` in ZIP 218. +pub const ORCHARD_BLOCK_ACTION_LIMIT: u32 = 330; + +/// Per-block limit on the total number of Sapling spends plus outputs, applied +/// from NU7 activation onwards. +/// +/// `SaplingBlockIOLimit` in ZIP 218. +pub const SAPLING_BLOCK_IO_LIMIT: u32 = 300; + +/// Per-block limit on the total number of Sprout JoinSplits, applied from NU7 +/// activation onwards. +/// +/// `SproutBlockJoinSplitLimit` in ZIP 218. +pub const SPROUT_BLOCK_JOINSPLIT_LIMIT: u32 = 25; + +/// Per-block budget for the total shielded cost across all pools, applied from +/// NU7 activation onwards. +/// +/// `GlobalShieldedBudget` in ZIP 218. It bounds the worst-case shielded sync +/// bandwidth per block whichever combination of pools a block uses. Sprout +/// JoinSplits count twice because each produces two shielded outputs. +pub const GLOBAL_SHIELDED_BUDGET: u32 = 330; + +/// The largest averaging window this build can use, which bounds the number of +/// relevant blocks a difficulty adjustment reads. +pub const MAX_POW_AVERAGING_WINDOW: usize = if ZIP218_ENABLED { + POST_NU7_POW_AVERAGING_WINDOW +} else { + PRE_NU7_POW_AVERAGING_WINDOW +}; /// The multiplier used to derive the testnet minimum difficulty block time gap /// threshold. @@ -402,12 +476,13 @@ impl NetworkUpgrade { pub fn target_spacing(&self) -> Duration { let spacing_seconds = match self { Genesis | BeforeOverwinter | Overwinter | Sapling => PRE_BLOSSOM_POW_TARGET_SPACING, - Blossom | Heartwood | Canopy | Nu5 | Nu6 | Nu6_1 | Nu6_2 | Nu6_3 | Nu7 => { + Blossom | Heartwood | Canopy | Nu5 | Nu6 | Nu6_1 | Nu6_2 | Nu6_3 => { POST_BLOSSOM_POW_TARGET_SPACING.into() } + Nu7 => Self::post_nu7_target_spacing_seconds(), #[cfg(zcash_unstable = "zfuture")] - ZFuture => POST_BLOSSOM_POW_TARGET_SPACING.into(), + ZFuture => Self::post_nu7_target_spacing_seconds(), }; Duration::seconds(spacing_seconds) @@ -430,6 +505,10 @@ impl NetworkUpgrade { NetworkUpgrade::Blossom, POST_BLOSSOM_POW_TARGET_SPACING.into(), ), + ( + NetworkUpgrade::Nu7, + NetworkUpgrade::post_nu7_target_spacing_seconds(), + ), ] .into_iter() .filter_map(move |(upgrade, spacing_seconds)| { @@ -497,7 +576,60 @@ impl NetworkUpgrade { /// /// `AveragingWindowTimespan` from the Zcash specification. pub fn averaging_window_timespan(&self) -> Duration { - self.target_spacing() * POW_AVERAGING_WINDOW.try_into().expect("fits in i32") + self.target_spacing() * self.averaging_window().try_into().expect("fits in i32") + } + + /// Returns the post-NU7 target spacing in seconds for this build. + /// + /// Without the `nu7-experimental` feature, NU7 keeps the post-Blossom spacing. + fn post_nu7_target_spacing_seconds() -> i64 { + if ZIP218_ENABLED { + POST_NU7_POW_TARGET_SPACING.into() + } else { + POST_BLOSSOM_POW_TARGET_SPACING.into() + } + } + + /// Returns the averaging window for difficulty threshold arithmetic mean + /// calculations. + /// + /// `PoWAveragingWindow` in ZIP 218, which widens the window at NU7. + pub fn averaging_window(&self) -> usize { + match self { + Genesis | BeforeOverwinter | Overwinter | Sapling | Blossom | Heartwood | Canopy + | Nu5 | Nu6 | Nu6_1 | Nu6_2 | Nu6_3 => PRE_NU7_POW_AVERAGING_WINDOW, + Nu7 => MAX_POW_AVERAGING_WINDOW, + + #[cfg(zcash_unstable = "zfuture")] + ZFuture => MAX_POW_AVERAGING_WINDOW, + } + } + + /// Returns the averaging window for `network` and `height`. + /// + /// See [`NetworkUpgrade::averaging_window`] for details. + pub fn averaging_window_for_height(network: &Network, height: block::Height) -> usize { + NetworkUpgrade::current(network, height).averaging_window() + } + + /// Returns `true` if NU7 is configured on `network` and active at `height`. + /// + /// NU7 has no default Mainnet or Testnet activation height, so this also + /// checks that `network` configures one before treating it as active. + pub fn is_nu7_active(network: &Network, height: block::Height) -> bool { + network + .activation_list() + .values() + .any(|upgrade| *upgrade == NetworkUpgrade::Nu7) + && NetworkUpgrade::current(network, height) >= NetworkUpgrade::Nu7 + } + + /// Returns `true` if the ZIP 218 rules are compiled in and active for + /// `network` at `height`. + /// + /// See [`ZIP218_ENABLED`] and [`NetworkUpgrade::is_nu7_active`]. + pub fn is_zip218_active(network: &Network, height: block::Height) -> bool { + ZIP218_ENABLED && Self::is_nu7_active(network, height) } /// Returns the averaging window timespan for `network` and `height`. diff --git a/crates/zakura-chain/src/transaction.rs b/crates/zakura-chain/src/transaction.rs index 53d518a6d3..7bb94f2654 100644 --- a/crates/zakura-chain/src/transaction.rs +++ b/crates/zakura-chain/src/transaction.rs @@ -307,6 +307,43 @@ impl fmt::Display for Transaction { } } +/// The shielded action counts of a transaction or block, used to enforce the +/// per-block shielded limits from ZIP 218. +/// +/// Each count saturates at [`u32::MAX`]. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ShieldedActionCounts { + /// The number of Orchard actions. + pub orchard_actions: u32, + /// The number of Sapling spends plus outputs. + pub sapling_ios: u32, + /// The number of Sprout JoinSplits. + pub sprout_joinsplits: u32, +} + +impl ShieldedActionCounts { + /// Returns the total shielded cost: + /// `orchard_actions + sapling_ios + 2 * sprout_joinsplits`. + /// + /// Sprout JoinSplits count twice because each produces two shielded outputs. + pub fn cost(&self) -> u32 { + self.orchard_actions + .saturating_add(self.sapling_ios) + .saturating_add(self.sprout_joinsplits.saturating_mul(2)) + } + + /// Returns the field-wise saturating sum of `self` and `other`. + pub fn saturating_add(self, other: Self) -> Self { + Self { + orchard_actions: self.orchard_actions.saturating_add(other.orchard_actions), + sapling_ios: self.sapling_ios.saturating_add(other.sapling_ios), + sprout_joinsplits: self + .sprout_joinsplits + .saturating_add(other.sprout_joinsplits), + } + } +} + impl Transaction { // identifiers and hashes @@ -1255,6 +1292,19 @@ impl Transaction { .flat_map(orchard::ShieldedData::actions) } + /// Returns this transaction's [`ShieldedActionCounts`], used to enforce the + /// ZIP 218 per-block shielded limits. + pub fn shielded_action_counts(&self) -> ShieldedActionCounts { + let count = |n: usize| u32::try_from(n).unwrap_or(u32::MAX); + + ShieldedActionCounts { + orchard_actions: count(self.orchard_actions().count()), + sapling_ios: count(self.sapling_spends_per_anchor().count()) + .saturating_add(count(self.sapling_outputs().count())), + sprout_joinsplits: count(self.joinsplit_count()), + } + } + /// Access the [`orchard::Nullifier`]s in this transaction, if there are any, /// regardless of version. pub fn orchard_nullifiers(&self) -> impl Iterator { diff --git a/crates/zakura-chain/src/transaction/arbitrary.rs b/crates/zakura-chain/src/transaction/arbitrary.rs index ec3a8b0b8f..9d67507354 100644 --- a/crates/zakura-chain/src/transaction/arbitrary.rs +++ b/crates/zakura-chain/src/transaction/arbitrary.rs @@ -1112,3 +1112,55 @@ pub fn insert_fake_orchard_shielded_data( _ => panic!("Fake V5 transaction is not V5"), } } + +/// Returns an empty V5 transaction with no shielded data, for use as a base in +/// shielded-action test fixtures. +pub fn empty_v5_transaction() -> Transaction { + Transaction::V5 { + network_upgrade: NetworkUpgrade::Nu5, + lock_time: LockTime::unlocked(), + expiry_height: block::Height(100), + inputs: Vec::new(), + outputs: Vec::new(), + sapling_shielded_data: None, + orchard_shielded_data: None, + } +} + +/// Returns a V5 transaction containing `count` Orchard actions. +pub fn fake_v5_with_orchard_actions(count: usize) -> Arc { + let mut tx = empty_v5_transaction(); + let shielded_data = insert_fake_orchard_shielded_data(&mut tx); + let action = shielded_data.actions.first().clone(); + shielded_data.actions = at_least_one![action; count]; + + Arc::new(tx) +} + +/// Returns a V5 transaction containing `count` Sapling outputs. +pub fn fake_v5_with_sapling_outputs(count: usize) -> Arc { + let mut runner = TestRunner::default(); + let mut shielded_data = any::>() + .new_tree(&mut runner) + .expect("sapling shielded data strategy is valid") + .current(); + let output = any::() + .new_tree(&mut runner) + .expect("sapling output strategy is valid") + .current(); + + shielded_data.transfers = sapling::TransferData::JustOutputs { + outputs: at_least_one![output; count], + }; + + let mut tx = empty_v5_transaction(); + match &mut tx { + Transaction::V5 { + sapling_shielded_data, + .. + } => *sapling_shielded_data = Some(shielded_data), + _ => unreachable!("empty_v5_transaction returns a V5 transaction"), + } + + Arc::new(tx) +} diff --git a/crates/zakura-consensus/Cargo.toml b/crates/zakura-consensus/Cargo.toml index 68fe0a7cb8..47546e11cb 100644 --- a/crates/zakura-consensus/Cargo.toml +++ b/crates/zakura-consensus/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zakura-consensus" -version = "8.0.1" +version = "9.0.0" authors.workspace = true description = "Implementation of Zcash consensus checks for the Zakura node. Internal crate, published to support cargo install zakura" license.workspace = true @@ -23,7 +23,11 @@ categories = ["asynchronous", "cryptography::cryptocurrencies"] default = [] # Experimental candidate NU7 consensus rules, dormant until NU7 activates. -nu7-experimental = [] +nu7-experimental = [ + "zakura-chain/nu7-experimental", + "zakura-state/nu7-experimental", + "zakura-header-chain/nu7-experimental", +] # Production features that activate extra dependencies, or extra features in dependencies @@ -71,10 +75,10 @@ tower-fallback = { package = "zakura-tower-fallback", path = "../tower-fallback/ tower-batch-control = { package = "zakura-tower-batch-control", path = "../tower-batch-control/", version = "1.3.0" } zakura-script = { path = "../zakura-script", version = "3.2.3" } -zakura-state = { path = "../zakura-state", version = "8.0.0" } +zakura-state = { path = "../zakura-state", version = "8.0.1" } zakura-node-services = { path = "../zakura-node-services", version = "3.2.4" } -zakura-chain = { path = "../zakura-chain", version = "7.0.0" } -zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.0" } +zakura-chain = { path = "../zakura-chain", version = "7.0.1" } +zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.1" } zcash_protocol.workspace = true @@ -96,8 +100,8 @@ toml = { workspace = true } tokio = { workspace = true, features = ["full", "tracing", "test-util"] } -zakura-state = { path = "../zakura-state", version = "8.0.0", features = ["proptest-impl"] } -zakura-chain = { path = "../zakura-chain", version = "7.0.0", features = ["proptest-impl"] } +zakura-state = { path = "../zakura-state", version = "8.0.1", features = ["proptest-impl"] } +zakura-chain = { path = "../zakura-chain", version = "7.0.1", features = ["proptest-impl"] } zakura-test = { path = "../zakura-test/", version = "2.1.0" } criterion = { workspace = true, features = ["html_reports"] } diff --git a/crates/zakura-consensus/src/block.rs b/crates/zakura-consensus/src/block.rs index 532bfe5be5..3257a63534 100644 --- a/crates/zakura-consensus/src/block.rs +++ b/crates/zakura-consensus/src/block.rs @@ -436,6 +436,8 @@ where .map_err(VerifyBlockError::Time)?; let coinbase_tx = check::coinbase_is_first(&block)?; + check::shielded_action_limits_are_valid(&block.transactions, height, &network)?; + let expected_block_subsidy = zakura_chain::parameters::subsidy::block_subsidy(height, &network)?; diff --git a/crates/zakura-consensus/src/block/check.rs b/crates/zakura-consensus/src/block/check.rs index 8b8cd3262c..48aa1d451c 100644 --- a/crates/zakura-consensus/src/block/check.rs +++ b/crates/zakura-consensus/src/block/check.rs @@ -14,9 +14,10 @@ use zakura_chain::{ founders_reward, founders_reward_address, funding_stream_values, FundingStreamReceiver, ParameterSubsidy, SubsidyError, }, - Network, NetworkUpgrade, + Network, NetworkUpgrade, GLOBAL_SHIELDED_BUDGET, ORCHARD_BLOCK_ACTION_LIMIT, + SAPLING_BLOCK_IO_LIMIT, SPROUT_BLOCK_JOINSPLIT_LIMIT, }, - transaction::{self, Transaction}, + transaction::{self, ShieldedActionCounts, Transaction}, transparent::{Address, Output}, work::{difficulty::ExpandedDifficulty, equihash}, }; @@ -404,6 +405,83 @@ pub fn time_is_valid_at( zakura_header_chain::validate_future_time(header, now, *height, *hash) } +/// Returns `Ok(())` if the ZIP 218 per-block shielded limits hold for the sum of +/// `transactions` on `network` at `height`. +/// +/// The block verifier passes every transaction in the block. The transaction +/// verifier passes a single transaction, because a transaction whose own counts +/// exceed a per-block limit can never be mined, so the mempool rejects it on +/// submission. +/// +/// The limits apply only once ZIP 218 is compiled in and NU7 is active, so this +/// is a no-op in a default build and on any network without an NU7 activation +/// height. +/// +/// # Consensus +/// +/// > For each block at height `height` where `IsNU7Activated(height)`, the +/// > following limits MUST be satisfied: +/// > +/// > - The total number of Orchard actions across all transactions in the block +/// > MUST NOT exceed `OrchardBlockActionLimit`. +/// > - The total number of Sapling inputs and outputs across all transactions in +/// > the block MUST NOT exceed `SaplingBlockIOLimit`. +/// > - The total number of Sprout JoinSplits across all transactions in the +/// > block MUST NOT exceed `SproutBlockJoinSplitLimit`. +/// > - The total shielded cost across all pools MUST NOT exceed +/// > `GlobalShieldedBudget`, where that cost is +/// > `Σ orchard_actions + Σ (sapling_spends + sapling_outputs) + 2 * Σ joinsplits`. +/// +/// +pub fn shielded_action_limits_are_valid<'a>( + transactions: impl IntoIterator>, + height: Height, + network: &Network, +) -> Result<(), TransactionError> { + if !NetworkUpgrade::is_zip218_active(network, height) { + return Ok(()); + } + + let totals = transactions + .into_iter() + .map(|tx| tx.shielded_action_counts()) + .fold( + ShieldedActionCounts::default(), + ShieldedActionCounts::saturating_add, + ); + + if totals.orchard_actions > ORCHARD_BLOCK_ACTION_LIMIT { + return Err(TransactionError::OrchardActionsExceedBlockLimit { + actions: totals.orchard_actions, + limit: ORCHARD_BLOCK_ACTION_LIMIT, + }); + } + + if totals.sapling_ios > SAPLING_BLOCK_IO_LIMIT { + return Err(TransactionError::SaplingIOsExceedBlockLimit { + ios: totals.sapling_ios, + limit: SAPLING_BLOCK_IO_LIMIT, + }); + } + + if totals.sprout_joinsplits > SPROUT_BLOCK_JOINSPLIT_LIMIT { + return Err(TransactionError::SproutJoinSplitsExceedBlockLimit { + joinsplits: totals.sprout_joinsplits, + limit: SPROUT_BLOCK_JOINSPLIT_LIMIT, + }); + } + + let cost = totals.cost(); + if cost > GLOBAL_SHIELDED_BUDGET { + return Err(TransactionError::ShieldedCostExceedsBlockBudget { + cost, + limit: GLOBAL_SHIELDED_BUDGET, + }); + } + + Ok(()) +} + /// Check Merkle root validity. /// /// `transaction_hashes` is a precomputed list of transaction hashes. diff --git a/crates/zakura-consensus/src/block/subsidy.rs b/crates/zakura-consensus/src/block/subsidy.rs index 4ce911c3e7..3e2217f0f6 100644 --- a/crates/zakura-consensus/src/block/subsidy.rs +++ b/crates/zakura-consensus/src/block/subsidy.rs @@ -27,12 +27,14 @@ fn funding_stream_address_index( let funding_streams = network.funding_streams(height)?; let num_addresses = funding_streams.recipient(receiver)?.addresses().len(); - let index = 1u32 + let index: usize = 1i64 .checked_add(funding_stream_address_period(height, network))? .checked_sub(funding_stream_address_period( funding_streams.height_range().start, network, - ))? as usize; + ))? + .try_into() + .ok()?; assert!(index > 0 && index <= num_addresses); // spec formula will output an index starting at 1 but diff --git a/crates/zakura-consensus/src/block/tests.rs b/crates/zakura-consensus/src/block/tests.rs index 01b2c0f1ea..03d9c489c8 100644 --- a/crates/zakura-consensus/src/block/tests.rs +++ b/crates/zakura-consensus/src/block/tests.rs @@ -1598,3 +1598,289 @@ fn state_commit_context_errors_keep_misbehavior_scores() { let router_error = crate::router::RouterError::from(err); assert_eq!(router_error.misbehavior_score(), 100); } + +/// Tests for the ZIP 218 per-block shielded action limits. +/// +/// The limits only apply once the `nu7-experimental` feature is compiled in and +/// NU7 is active, so the rejection cases only exist in an experimental NU7 build. +mod zip218_shielded_action_limits { + use zakura_chain::{ + block::{Block, Height}, + parameters::{ + testnet::{ConfiguredActivationHeights, Parameters}, + Network, ORCHARD_BLOCK_ACTION_LIMIT, + }, + serialization::{ZcashDeserialize, ZcashDeserializeInto}, + transaction::arbitrary::fake_v5_with_orchard_actions, + }; + + use crate::block::check; + + #[cfg(feature = "nu7-experimental")] + use std::sync::Arc; + + #[cfg(feature = "nu7-experimental")] + use proptest::{ + arbitrary::any, + strategy::{Strategy, ValueTree}, + test_runner::TestRunner, + }; + + #[cfg(feature = "nu7-experimental")] + use zakura_chain::{ + parameters::{ + GLOBAL_SHIELDED_BUDGET, SAPLING_BLOCK_IO_LIMIT, SPROUT_BLOCK_JOINSPLIT_LIMIT, + }, + primitives::Groth16Proof, + transaction::{ + arbitrary::fake_v5_with_sapling_outputs, JoinSplitData, LockTime, Transaction, + }, + }; + + #[cfg(feature = "nu7-experimental")] + use crate::error::TransactionError; + + /// Every historical block satisfies the limits, because NU7 is not active on + /// Mainnet. A block with no shielded data also satisfies them once NU7 is + /// active. + #[test] + fn historical_blocks_satisfy_the_limits() { + let _init_guard = zakura_test::init(); + + for block in zakura_test::vectors::BLOCKS.iter() { + let block = block + .zcash_deserialize_into::() + .expect("block is structurally valid"); + + check::shielded_action_limits_are_valid( + &block.transactions, + block + .coinbase_height() + .expect("block has a coinbase height"), + &Network::Mainnet, + ) + .expect("a historical Mainnet block satisfies the shielded action limits"); + } + + let genesis = + Block::zcash_deserialize(&zakura_test::vectors::BLOCK_MAINNET_GENESIS_BYTES[..]) + .expect("mainnet genesis deserializes"); + + check::shielded_action_limits_are_valid( + &genesis.transactions, + Height(1), + &nu7_active_testnet(), + ) + .expect("a block with no shielded data satisfies the post-NU7 limits"); + } + + #[test] + #[cfg(feature = "nu7-experimental")] + fn limits_activate_at_the_nu7_height() { + let network = nu7_activation_testnet(2); + let over_limit_tx = + fake_v5_with_orchard_actions(limit_plus_one(ORCHARD_BLOCK_ACTION_LIMIT)); + + check::shielded_action_limits_are_valid( + [over_limit_tx.clone()].iter(), + Height(1), + &network, + ) + .expect("the limits are inactive below the NU7 activation height"); + + let err = + check::shielded_action_limits_are_valid([over_limit_tx].iter(), Height(2), &network) + .expect_err("the limits are enforced at the NU7 activation height"); + + assert_eq!( + err, + TransactionError::OrchardActionsExceedBlockLimit { + actions: ORCHARD_BLOCK_ACTION_LIMIT + 1, + limit: ORCHARD_BLOCK_ACTION_LIMIT, + } + ); + } + + /// Without the `nu7-experimental` feature, the limits stay inactive even at an NU7 + /// height. + #[test] + #[cfg(not(feature = "nu7-experimental"))] + fn limits_are_inactive_without_the_feature() { + let over_limit_tx = + fake_v5_with_orchard_actions(limit_plus_one(ORCHARD_BLOCK_ACTION_LIMIT)); + + check::shielded_action_limits_are_valid( + [over_limit_tx].iter(), + Height(1), + &nu7_active_testnet(), + ) + .expect("the limits are inactive without the experimental NU7 feature"); + } + + #[test] + #[cfg(feature = "nu7-experimental")] + fn counts_at_the_per_pool_limits_are_accepted() { + let cases: [(&str, Arc); 3] = [ + ( + "Orchard actions", + fake_v5_with_orchard_actions(limit_as_usize(ORCHARD_BLOCK_ACTION_LIMIT)), + ), + ( + "Sapling spends and outputs", + fake_v5_with_sapling_outputs(limit_as_usize(SAPLING_BLOCK_IO_LIMIT)), + ), + ( + "Sprout JoinSplits", + fake_v4_with_sprout_joinsplits(limit_as_usize(SPROUT_BLOCK_JOINSPLIT_LIMIT)), + ), + ]; + + for (pool, tx) in cases { + check::shielded_action_limits_are_valid([tx].iter(), Height(1), &nu7_active_testnet()) + .unwrap_or_else(|error| { + panic!("{pool} exactly at the per-block limit must pass, got {error}") + }); + } + } + + #[test] + #[cfg(feature = "nu7-experimental")] + fn a_cost_at_the_global_budget_is_accepted() { + // One JoinSplit costs 2, so the rest of the budget can hold that many + // fewer Orchard actions. + let orchard_actions = limit_as_usize( + GLOBAL_SHIELDED_BUDGET + .checked_sub(2) + .expect("the global shielded budget covers at least one JoinSplit"), + ); + + check::shielded_action_limits_are_valid( + [ + fake_v5_with_orchard_actions(orchard_actions), + fake_v4_with_sprout_joinsplits(1), + ] + .iter(), + Height(1), + &nu7_active_testnet(), + ) + .expect("a combined shielded cost exactly at the global budget must pass"); + } + + #[test] + #[cfg(feature = "nu7-experimental")] + fn sapling_ios_above_the_limit_are_rejected() { + let err = check::shielded_action_limits_are_valid( + [fake_v5_with_sapling_outputs(limit_plus_one( + SAPLING_BLOCK_IO_LIMIT, + ))] + .iter(), + Height(1), + &nu7_active_testnet(), + ) + .expect_err("Sapling spends and outputs above the per-block limit must fail"); + + assert_eq!( + err, + TransactionError::SaplingIOsExceedBlockLimit { + ios: SAPLING_BLOCK_IO_LIMIT + 1, + limit: SAPLING_BLOCK_IO_LIMIT, + } + ); + } + + #[test] + #[cfg(feature = "nu7-experimental")] + fn sprout_joinsplits_above_the_limit_are_rejected() { + let err = check::shielded_action_limits_are_valid( + [fake_v4_with_sprout_joinsplits(limit_plus_one( + SPROUT_BLOCK_JOINSPLIT_LIMIT, + ))] + .iter(), + Height(1), + &nu7_active_testnet(), + ) + .expect_err("Sprout JoinSplits above the per-block limit must fail"); + + assert_eq!( + err, + TransactionError::SproutJoinSplitsExceedBlockLimit { + joinsplits: SPROUT_BLOCK_JOINSPLIT_LIMIT + 1, + limit: SPROUT_BLOCK_JOINSPLIT_LIMIT, + } + ); + } + + /// A block can satisfy every per-pool limit and still exceed the global + /// budget. + #[test] + #[cfg(feature = "nu7-experimental")] + fn a_cost_above_the_global_budget_is_rejected() { + let err = check::shielded_action_limits_are_valid( + [ + fake_v5_with_orchard_actions(limit_as_usize(ORCHARD_BLOCK_ACTION_LIMIT)), + fake_v5_with_sapling_outputs(1), + ] + .iter(), + Height(1), + &nu7_active_testnet(), + ) + .expect_err("a combined shielded cost above the global budget must fail"); + + assert_eq!( + err, + TransactionError::ShieldedCostExceedsBlockBudget { + cost: GLOBAL_SHIELDED_BUDGET + 1, + limit: GLOBAL_SHIELDED_BUDGET, + } + ); + } + + fn nu7_active_testnet() -> Network { + nu7_activation_testnet(1) + } + + fn nu7_activation_testnet(nu7_activation_height: u32) -> Network { + Parameters::build() + .with_slow_start_interval(Height(0)) + .with_activation_heights(ConfiguredActivationHeights { + nu7: Some(nu7_activation_height), + ..Default::default() + }) + .expect("activation heights are valid") + .clear_funding_streams() + .to_network() + .expect("configured testnet is valid") + } + + #[cfg(feature = "nu7-experimental")] + fn limit_as_usize(limit: u32) -> usize { + usize::try_from(limit).expect("a shielded action limit fits in usize") + } + + fn limit_plus_one(limit: u32) -> usize { + usize::try_from(limit + 1).expect("a shielded action limit fits in usize") + } + + /// Returns a V4 transaction containing `count` Sprout JoinSplits. + #[cfg(feature = "nu7-experimental")] + fn fake_v4_with_sprout_joinsplits(count: usize) -> Arc { + let mut runner = TestRunner::default(); + let mut joinsplit_data = any::>() + .new_tree(&mut runner) + .expect("sprout JoinSplit data strategy is valid") + .current(); + let rest_len = count + .checked_sub(1) + .expect("a Sprout JoinSplit test count is at least one"); + joinsplit_data.rest = vec![joinsplit_data.first.clone(); rest_len]; + + Arc::new(Transaction::V4 { + inputs: Vec::new(), + outputs: Vec::new(), + lock_time: LockTime::unlocked(), + expiry_height: Height(100), + joinsplit_data: Some(joinsplit_data), + sapling_shielded_data: None, + }) + } +} diff --git a/crates/zakura-consensus/src/error.rs b/crates/zakura-consensus/src/error.rs index 76e323712f..a93f0a042b 100644 --- a/crates/zakura-consensus/src/error.rs +++ b/crates/zakura-consensus/src/error.rs @@ -272,6 +272,22 @@ pub enum TransactionError { #[error("wrong tx format: tx version is ≥ 5, but `nConsensusBranchId` is missing")] MissingConsensusBranchId, + #[error("Orchard action count {actions} exceeds the per-block limit of {limit}")] + OrchardActionsExceedBlockLimit { actions: u32, limit: u32 }, + + #[error("Sapling spends + outputs count {ios} exceeds the per-block limit of {limit}")] + SaplingIOsExceedBlockLimit { ios: u32, limit: u32 }, + + #[error("Sprout JoinSplit count {joinsplits} exceeds the per-block limit of {limit}")] + SproutJoinSplitsExceedBlockLimit { joinsplits: u32, limit: u32 }, + + #[error( + "shielded cost {cost} \ + (Orchard actions + Sapling spends + Sapling outputs + 2 * Sprout JoinSplits) \ + exceeds the per-block global shielded budget of {limit}" + )] + ShieldedCostExceedsBlockBudget { cost: u32, limit: u32 }, + #[error("input/output error")] Io(String), @@ -485,6 +501,18 @@ impl TransactionError { } Self::WrongConsensusBranchId => consensus("transaction.wrong_consensus_branch_id"), Self::MissingConsensusBranchId => consensus("transaction.missing_consensus_branch_id"), + Self::OrchardActionsExceedBlockLimit { .. } => { + consensus("transaction.orchard_actions_exceed_block_limit") + } + Self::SaplingIOsExceedBlockLimit { .. } => { + consensus("transaction.sapling_ios_exceed_block_limit") + } + Self::SproutJoinSplitsExceedBlockLimit { .. } => { + consensus("transaction.sprout_joinsplits_exceed_block_limit") + } + Self::ShieldedCostExceedsBlockBudget { .. } => { + consensus("transaction.shielded_cost_exceeds_block_budget") + } Self::Io(_) | Self::TryFromSlice(_) | Self::Other(_) => { BodyVerificationClass::Retryable(TransientBodyFailureKind::VerifierUnavailable) } @@ -547,6 +575,10 @@ impl TransactionError { | IronwoodProofSize | WrongConsensusBranchId | MissingConsensusBranchId + | OrchardActionsExceedBlockLimit { .. } + | SaplingIOsExceedBlockLimit { .. } + | SproutJoinSplitsExceedBlockLimit { .. } + | ShieldedCostExceedsBlockBudget { .. } | LockedUntilAfterBlockHeight(_) | LockedUntilAfterBlockTime(_) => 100, diff --git a/crates/zakura-consensus/src/transaction.rs b/crates/zakura-consensus/src/transaction.rs index 0866ec0823..25aa1aaf8a 100644 --- a/crates/zakura-consensus/src/transaction.rs +++ b/crates/zakura-consensus/src/transaction.rs @@ -471,6 +471,14 @@ where } check::sapling_point_encodings_are_valid(&tx)?; + // A transaction whose own shielded counts exceed a per-block ZIP 218 + // limit can never be mined, so reject it on submission. + crate::block::check::shielded_action_limits_are_valid( + std::iter::once(&tx), + req.height(), + &network, + )?; + // Soft fork: temporarily require transactions to not contain Orchard actions. // // This soft fork was added while NU 6.1 was the active epoch on the Zcash @@ -985,7 +993,7 @@ where let tx = request.transaction(); let nu = request.upgrade(network); - Self::verify_v4_transaction_network_upgrade(&tx, nu)?; + Self::verify_v4_transaction_network_upgrade(&tx, network, request.height(), nu)?; let sapling_bundle = cached_ffi_transaction.sighasher().sapling_bundle(); @@ -1002,11 +1010,28 @@ where .and(Self::verify_sapling_bundle(sapling_bundle, &sighash, tx_id))) } - /// Verifies if a V4 `transaction` is supported by `network_upgrade`. + /// Verifies if a V4 `transaction` is supported by `network_upgrade` at `height` on + /// `network`. fn verify_v4_transaction_network_upgrade( transaction: &Transaction, + network: &Network, + height: block::Height, network_upgrade: NetworkUpgrade, ) -> Result<(), TransactionError> { + // # Consensus + // + // > [NU7 onward] The transaction version number MUST be 5 or 6. + // + // https://zips.z.cash/zip-2003 + // + // ZIP 2003 deprecates V4 transactions at NU7. + if network.is_v4_deprecated(height) { + return Err(TransactionError::UnsupportedByNetworkUpgrade( + transaction.version(), + network_upgrade, + )); + } + match network_upgrade { // Supports V4 transactions // @@ -1031,7 +1056,8 @@ where | NetworkUpgrade::Nu6 | NetworkUpgrade::Nu6_1 | NetworkUpgrade::Nu6_2 - | NetworkUpgrade::Nu6_3 => Ok(()), + | NetworkUpgrade::Nu6_3 + | NetworkUpgrade::Nu7 => Ok(()), #[cfg(zcash_unstable = "zfuture")] NetworkUpgrade::ZFuture => Ok(()), @@ -1039,8 +1065,7 @@ where // Does not support V4 transactions NetworkUpgrade::Genesis | NetworkUpgrade::BeforeOverwinter - | NetworkUpgrade::Overwinter - | NetworkUpgrade::Nu7 => Err(TransactionError::UnsupportedByNetworkUpgrade( + | NetworkUpgrade::Overwinter => Err(TransactionError::UnsupportedByNetworkUpgrade( transaction.version(), network_upgrade, )), diff --git a/crates/zakura-consensus/src/transaction/tests.rs b/crates/zakura-consensus/src/transaction/tests.rs index d73e21e365..7158680dc8 100644 --- a/crates/zakura-consensus/src/transaction/tests.rs +++ b/crates/zakura-consensus/src/transaction/tests.rs @@ -46,7 +46,7 @@ use zakura_chain::{ironwood, orchard}; use zakura_node_services::mempool; use zakura_state::ValidateContextError; -use zakura_test::mock_service::MockService; +use zakura_test::mock_service::{MockService, PanicAssertion}; use crate::{error::TransactionError, primitives, transaction::POLL_MEMPOOL_DELAY, BoxError}; @@ -4452,6 +4452,93 @@ async fn v5_with_duplicate_orchard_action() { } } +/// Checks that ZIP 2003 accepts V4 transactions below NU7 and rejects them from NU7. +#[test] +fn v4_deprecation_boundary() { + let _init_guard = zakura_test::init(); + + let nu7 = Height(2_000_000); + let tx = test_transactions(&Network::Mainnet) + .map(|(_, tx)| tx) + .find(|tx| matches!(**tx, Transaction::V4 { .. })) + .expect("V4 tx"); + + let activation_heights = ConfiguredActivationHeights { + before_overwinter: Some(1), + overwinter: Some(2), + sapling: Some(3), + blossom: Some(4), + heartwood: Some(5), + canopy: Some(6), + nu5: Some(7), + nu6: Some(8), + nu6_1: Some(9), + nu6_2: Some(10), + nu6_3: Some(11), + nu7: Some(nu7.0), + }; + + let at_nu7 = Parameters::build() + .with_activation_heights(activation_heights) + .expect("activation heights are valid") + .clear_funding_streams() + .to_network() + .expect("failed to build configured network"); + + assert_eq!( + at_nu7.v4_deprecation_height(), + cfg!(feature = "nu7-experimental").then_some(nu7) + ); + assert!( + verify_v4_at(&at_nu7, &tx, nu7.previous().expect("height")).is_ok(), + "a V4 transaction must be valid below the deprecation height", + ); + if cfg!(feature = "nu7-experimental") { + assert_eq!( + verify_v4_at(&at_nu7, &tx, nu7), + Err(TransactionError::UnsupportedByNetworkUpgrade( + 4, + NetworkUpgrade::Nu7 + )), + "a V4 transaction must be invalid at the deprecation height", + ); + } else { + assert!( + verify_v4_at(&at_nu7, &tx, nu7).is_ok(), + "a default build must keep accepting V4 transactions", + ); + } + + // A network without NU7 keeps accepting V4 transactions. + let no_nu7 = Parameters::build() + .to_network() + .expect("failed to build configured network"); + + assert_eq!(no_nu7.v4_deprecation_height(), None); + assert!(!no_nu7.is_v4_deprecated(Height::MAX)); +} + +/// A [`Verifier`] with concrete service types, so a test can name its associated +/// functions without the compiler inferring the services from a call. +type TestVerifier = Verifier< + MockService, + MockService, +>; + +/// Runs the V4 network upgrade check for `tx` at `height` on `network`. +fn verify_v4_at( + network: &Network, + tx: &Transaction, + height: Height, +) -> Result<(), TransactionError> { + TestVerifier::verify_v4_transaction_network_upgrade( + tx, + network, + height, + NetworkUpgrade::current(network, height), + ) +} + /// Checks the activation boundary of the temporary Orchard-disabling soft fork: /// it is inactive below the configured height and active at and above it, can be /// disabled entirely, and Mainnet uses its fixed activation height. diff --git a/crates/zakura-header-chain/Cargo.toml b/crates/zakura-header-chain/Cargo.toml index f037e12683..c7e901ea3c 100644 --- a/crates/zakura-header-chain/Cargo.toml +++ b/crates/zakura-header-chain/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zakura-header-chain" -version = "2.1.0" +version = "2.1.1" authors.workspace = true description = "Fork-aware header-chain domain types and transition engine for Zakura" license.workspace = true @@ -12,6 +12,12 @@ edition.workspace = true rust-version.workspace = true [features] + +# Experimental candidate NU7 consensus rules, dormant until NU7 activates. +nu7-experimental = [ + "zakura-chain/nu7-experimental", +] + fuzz-impl = [] test-support = [] @@ -20,7 +26,7 @@ chrono = { workspace = true } rayon = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } -zakura-chain = { path = "../zakura-chain", version = "7.0.0" } +zakura-chain = { path = "../zakura-chain", version = "7.0.1" } [dev-dependencies] criterion = { workspace = true } diff --git a/crates/zakura-header-chain/conformance.toml b/crates/zakura-header-chain/conformance.toml index 58497912f4..942f7e7ab2 100644 --- a/crates/zakura-header-chain/conformance.toml +++ b/crates/zakura-header-chain/conformance.toml @@ -382,7 +382,7 @@ id = "LC-ANCHOR-03" name = "Post-anchor validation context" status = "implemented" owner = "zakura_state::HeaderChainStore::validation_context" -tests = ["HV-06::later_anchor_predecessor_context_has_exact_one_to_twenty_eight_boundary", "HV-06::later_anchor_predecessor_context_rejects_gap_hash_and_link_corruption", "HV-06::clean_store_initializes_only_from_finalized_full_state", "HV-06::atomic_finality_context_can_use_a_newly_staged_anchor_path", "AUD-14::every_crash_boundary_reopens_to_complete_transition"] +tests = ["HV-06::later_anchor_predecessor_context_has_the_exact_span_boundary", "HV-06::later_anchor_predecessor_context_rejects_gap_hash_and_link_corruption", "HV-06::clean_store_initializes_only_from_finalized_full_state", "HV-06::atomic_finality_context_can_use_a_newly_staged_anchor_path", "AUD-14::every_crash_boundary_reopens_to_complete_transition"] networks = ["mainnet", "testnet", "custom"] [[rule]] diff --git a/crates/zakura-header-chain/src/transition/types/preparation.rs b/crates/zakura-header-chain/src/transition/types/preparation.rs index 4f5c1d5448..70941bc04a 100644 --- a/crates/zakura-header-chain/src/transition/types/preparation.rs +++ b/crates/zakura-header-chain/src/transition/types/preparation.rs @@ -372,7 +372,16 @@ mod tests { #[test] fn validation_lease_coherence_enforces_context_boundaries() { let network = Network::new_regtest(RegtestParameters::default()); - for (height, expected_len) in [(0, 1), (27, 28), (28, 28), (40, 28)] { + // A lease retains every predecessor below the difficulty adjustment + // span, and caps at the span above it. + let span = crate::POW_ADJUSTMENT_BLOCK_SPAN; + let span_height = u32::try_from(span).expect("the difficulty adjustment span fits in u32"); + for (height, expected_len) in [ + (0, 1), + (span_height - 1, span), + (span_height, span), + (span_height + 12, span), + ] { let lease = lease_at(height); assert_eq!(lease.predecessors.len(), expected_len, "height {height}"); assert!( diff --git a/crates/zakura-header-chain/src/validation/contextual/adjusted_difficulty.rs b/crates/zakura-header-chain/src/validation/contextual/adjusted_difficulty.rs index 2d9596456a..e5b82c072a 100644 --- a/crates/zakura-header-chain/src/validation/contextual/adjusted_difficulty.rs +++ b/crates/zakura-header-chain/src/validation/contextual/adjusted_difficulty.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Duration, Utc}; use thiserror::Error; use zakura_chain::{ block::{self, Block}, - parameters::{Network, NetworkUpgrade, POW_AVERAGING_WINDOW}, + parameters::{Network, NetworkUpgrade}, work::difficulty::{CompactDifficulty, ExpandedDifficulty, ParameterDifficulty as _, U256}, BoundedVec, }; @@ -161,6 +161,14 @@ impl AdjustedDifficulty { self.network.clone() } + /// Returns the averaging window in force at the candidate height. + /// + /// `PoWAveragingWindow` in the Zcash specification, which ZIP 218 widens at + /// NU7. + fn averaging_window(&self) -> usize { + NetworkUpgrade::averaging_window_for_height(&self.network, self.candidate_height) + } + /// Calculate the expected `difficulty_threshold` from the candidate block's time and height, /// the network, and the /// `difficulty_threshold`s and `time`s from the previous @@ -192,7 +200,7 @@ impl AdjustedDifficulty { /// The difficulty calculation implements `ThresholdBits` from the Zcash specification. /// `ThresholdBits` excludes the Testnet minimum difficulty adjustment. fn threshold_bits(&self) -> CompactDifficulty { - let averaging_window_height = u32::try_from(POW_AVERAGING_WINDOW) + let averaging_window_height = u32::try_from(self.averaging_window()) .expect("averaging window is much smaller than u32::MAX"); if self.candidate_height.0 <= averaging_window_height { @@ -230,10 +238,11 @@ impl AdjustedDifficulty { // `threshold_bits` returns `PoWLimit` before it calls this function at early-chain heights. // A valid relevant chain contains at least 17 blocks at later heights. + let averaging_window = self.averaging_window(); let averaging_window_thresholds = - &self.relevant_difficulty_thresholds.as_slice()[0..POW_AVERAGING_WINDOW]; + &self.relevant_difficulty_thresholds.as_slice()[0..averaging_window]; - let divisor: U256 = POW_AVERAGING_WINDOW.into(); + let divisor: U256 = averaging_window.into(); let mut quotient_total = U256::zero(); let mut remainder_total = U256::zero(); for compact in averaging_window_thresholds { @@ -246,7 +255,7 @@ impl AdjustedDifficulty { .expect("the sum of divided targets is at most U256::MAX"); remainder_total = remainder_total .checked_add(target % divisor) - .expect("17 remainders smaller than 17 fit in U256"); + .expect("a window of remainders smaller than the window fits in U256"); } ExpandedDifficulty::from( quotient_total @@ -308,11 +317,12 @@ impl AdjustedDifficulty { let newer_median = self.median_time_past(); // MedianTime(height : N) := median([ nTime(𝑖) for 𝑖 from max(0, height − PoWMedianBlockSpan) up to max(0, height − 1) ]) - let older_median = if self.relevant_times.len() > POW_AVERAGING_WINDOW { + let averaging_window = self.averaging_window(); + let older_median = if self.relevant_times.len() > averaging_window { let older_times: Vec<_> = self .relevant_times .iter() - .skip(POW_AVERAGING_WINDOW) + .skip(averaging_window) .cloned() .take(POW_MEDIAN_BLOCK_SPAN) .collect(); diff --git a/crates/zakura-header-chain/src/validation/contextual/constants.rs b/crates/zakura-header-chain/src/validation/contextual/constants.rs index 2829987133..9898a28ec2 100644 --- a/crates/zakura-header-chain/src/validation/contextual/constants.rs +++ b/crates/zakura-header-chain/src/validation/contextual/constants.rs @@ -1,4 +1,4 @@ -use zakura_chain::parameters::POW_AVERAGING_WINDOW; +use zakura_chain::parameters::MAX_POW_AVERAGING_WINDOW; /// The median block span for time median calculations. /// @@ -9,7 +9,12 @@ pub const POW_MEDIAN_BLOCK_SPAN: usize = 11; /// /// `PoWAveragingWindow + PoWMedianBlockSpan` in the Zcash specification based on /// > ActualTimespan(height : N) := MedianTime(height) − MedianTime(height − PoWAveragingWindow) -pub const POW_ADJUSTMENT_BLOCK_SPAN: usize = POW_AVERAGING_WINDOW + POW_MEDIAN_BLOCK_SPAN; +/// +/// ZIP 218 widens `PoWAveragingWindow` at NU7, so this span covers the largest +/// window the build can use at any height. An experimental NU7 build therefore carries +/// this wider context from genesis onwards and ignores the entries beyond the +/// window in force at the candidate height. +pub const POW_ADJUSTMENT_BLOCK_SPAN: usize = MAX_POW_AVERAGING_WINDOW + POW_MEDIAN_BLOCK_SPAN; /// Durable predecessors needed below a separately retained parent frontier. pub const POW_PREDECESSOR_CONTEXT_SPAN: usize = POW_ADJUSTMENT_BLOCK_SPAN - 1; diff --git a/crates/zakura-header-chain/src/validation/contextual/tests/arithmetic.rs b/crates/zakura-header-chain/src/validation/contextual/tests/arithmetic.rs index b299501269..e2ffc10f2c 100644 --- a/crates/zakura-header-chain/src/validation/contextual/tests/arithmetic.rs +++ b/crates/zakura-header-chain/src/validation/contextual/tests/arithmetic.rs @@ -5,7 +5,8 @@ use zakura_chain::{ work::difficulty::{ExpandedDifficulty, ParameterDifficulty as _, U256}, }; -use super::super::AdjustedDifficulty; +use super::super::{AdjustedDifficulty, POW_ADJUSTMENT_BLOCK_SPAN}; +use zakura_chain::parameters::NetworkUpgrade; #[test] fn custom_target_scaling_clamps_before_overflowing_u256() { @@ -17,10 +18,16 @@ fn custom_target_scaling_clamps_before_overflowing_u256() { .expect("the maximum compact-representable target is valid") .to_network() .expect("the custom network parameters are valid"); - let mut context = vec![(compact, candidate_time - Duration::seconds(1)); 17]; + // The recent averaging window is tightly spaced and everything older is far + // apart, so the actual timespan is large enough to clamp the scaled mean. + // The context always spans `POW_ADJUSTMENT_BLOCK_SPAN` blocks, which can be + // wider than the averaging window in force at this height. + let candidate_height = block::Height(700_000); + let averaging_window = NetworkUpgrade::averaging_window_for_height(&network, candidate_height); + let mut context = vec![(compact, candidate_time - Duration::seconds(1)); averaging_window]; context.extend(vec![ (compact, candidate_time - Duration::seconds(100_000)); - 11 + POW_ADJUSTMENT_BLOCK_SPAN - averaging_window ]); let adjustment = AdjustedDifficulty::new_from_header_time( candidate_time, diff --git a/crates/zakura-header-chain/src/validation/contextual/tests/validation.rs b/crates/zakura-header-chain/src/validation/contextual/tests/validation.rs index 2f21b367d2..798c0da0e7 100644 --- a/crates/zakura-header-chain/src/validation/contextual/tests/validation.rs +++ b/crates/zakura-header-chain/src/validation/contextual/tests/validation.rs @@ -119,7 +119,9 @@ fn difficulty_windows_upgrades_testnet_minimum_and_partitions_match() { continue; } let spacing = NetworkUpgrade::target_spacing_for_height(&network, height); - let context_len = usize::try_from(height.0.min(28)) + let span = u32::try_from(POW_ADJUSTMENT_BLOCK_SPAN) + .expect("the difficulty adjustment span fits in u32"); + let context_len = usize::try_from(height.0.min(span)) .expect("bounded test context length fits in usize"); let context = context(&network, candidate_time, spacing, context_len); validate_with_expected_target(&network, height, candidate_time, &context) @@ -131,7 +133,7 @@ fn difficulty_windows_upgrades_testnet_minimum_and_partitions_match() { let activation_height = block::Height(299_188); let spacing = NetworkUpgrade::target_spacing_for_height(&testnet, activation_height); let previous_time = candidate_time - spacing * 6; - let mut exact_gap = context(&testnet, candidate_time, spacing, 28); + let mut exact_gap = context(&testnet, candidate_time, spacing, POW_ADJUSTMENT_BLOCK_SPAN); exact_gap[0].1 = previous_time; let previous_height = (activation_height - 1).expect("height is positive"); let exact_gap_target = AdjustedDifficulty::new_from_header_time( @@ -148,7 +150,7 @@ fn difficulty_windows_upgrades_testnet_minimum_and_partitions_match() { ); let minimum_time = candidate_time + Duration::seconds(1); - let minimum_context = context(&testnet, minimum_time, spacing, 28) + let minimum_context = context(&testnet, minimum_time, spacing, POW_ADJUSTMENT_BLOCK_SPAN) .into_iter() .enumerate() .map(|(index, (difficulty, time))| { @@ -275,7 +277,13 @@ fn median_and_production_max_time_boundaries_are_exact() { ] { let context = vec![ (network.target_difficulty_limit().to_compact(), base); - usize::try_from(height.0.min(28)).expect("bounded height fits in usize") + usize::try_from( + height.0.min( + u32::try_from(POW_ADJUSTMENT_BLOCK_SPAN) + .expect("the difficulty adjustment span fits in u32"), + ) + ) + .expect("bounded height fits in usize") ]; assert!(matches!( validate_with_expected_target(&network, height, base, &context), diff --git a/crates/zakura-rpc/Cargo.toml b/crates/zakura-rpc/Cargo.toml index 81c51c5aaa..c0300d6831 100644 --- a/crates/zakura-rpc/Cargo.toml +++ b/crates/zakura-rpc/Cargo.toml @@ -24,6 +24,14 @@ categories = [ exclude = ["*.proto"] [features] + +# Experimental candidate NU7 consensus rules, dormant until NU7 activates. +nu7-experimental = [ + "zakura-chain/nu7-experimental", + "zakura-consensus/nu7-experimental", + "zakura-state/nu7-experimental", +] + # Production features that activate extra dependencies, or extra features in # dependencies @@ -108,16 +116,16 @@ zcash_protocol = { workspace = true } zcash_script = { workspace = true } zcash_transparent = { workspace = true } -zakura-chain = { path = "../zakura-chain", version = "7.0.0", features = [ +zakura-chain = { path = "../zakura-chain", version = "7.0.1", features = [ "json-conversion", ] } -zakura-consensus = { path = "../zakura-consensus", version = "8.0.0" } +zakura-consensus = { path = "../zakura-consensus", version = "9.0.0" } zakura-network = { path = "../zakura-network", version = "8.0.0" } zakura-node-services = { path = "../zakura-node-services", version = "3.2.4", features = [ "rpc-client", ] } zakura-script = { path = "../zakura-script", version = "3.2.3" } -zakura-state = { path = "../zakura-state", version = "8.0.0" } +zakura-state = { path = "../zakura-state", version = "8.0.1" } rustls = { version = "0.23.40", default-features = false, features = ["logging", "ring", "std", "tls12"] } tokio-rustls = { version = "0.26.4", default-features = false, features = ["logging", "ring", "tls12"] } # Only used to read the validity dates of the configured RPC TLS certificates. @@ -136,16 +144,16 @@ proptest = { workspace = true } tokio = { workspace = true, features = ["full", "tracing", "test-util"] } -zakura-chain = { path = "../zakura-chain", version = "7.0.0", features = [ +zakura-chain = { path = "../zakura-chain", version = "7.0.1", features = [ "proptest-impl", ] } -zakura-consensus = { path = "../zakura-consensus", version = "8.0.0", features = [ +zakura-consensus = { path = "../zakura-consensus", version = "9.0.0", features = [ "proptest-impl", ] } zakura-network = { path = "../zakura-network", version = "8.0.0", features = [ "proptest-impl", ] } -zakura-state = { path = "../zakura-state", version = "8.0.0", features = [ +zakura-state = { path = "../zakura-state", version = "8.0.1", features = [ "proptest-impl", ] } diff --git a/crates/zakura-rpc/src/methods/types/get_block_template/zip317.rs b/crates/zakura-rpc/src/methods/types/get_block_template/zip317.rs index a134ea8f3a..5e2f92df53 100644 --- a/crates/zakura-rpc/src/methods/types/get_block_template/zip317.rs +++ b/crates/zakura-rpc/src/methods/types/get_block_template/zip317.rs @@ -16,9 +16,14 @@ use rand::{ use zakura_chain::{ amount::{self, Amount}, block::{Height, MAX_BLOCK_BYTES}, - parameters::Network, + parameters::{ + Network, NetworkUpgrade, GLOBAL_SHIELDED_BUDGET, ORCHARD_BLOCK_ACTION_LIMIT, + SAPLING_BLOCK_IO_LIMIT, SPROUT_BLOCK_JOINSPLIT_LIMIT, + }, serialization::{CompactSizeMessage, TrustedPreallocate, ZcashDeserializeInto, ZcashSerialize}, - transaction::{self, zip317::BLOCK_UNPAID_ACTION_LIMIT, VerifiedUnminedTx}, + transaction::{ + self, zip317::BLOCK_UNPAID_ACTION_LIMIT, ShieldedActionCounts, VerifiedUnminedTx, + }, work::equihash::Solution, }; use zakura_consensus::MAX_BLOCK_SIGOPS; @@ -131,18 +136,7 @@ pub fn select_mempool_transactions( let mut selected_txs = Vec::new(); // Set up limit tracking - let max_block_bytes: usize = MAX_BLOCK_BYTES.try_into().expect("fits in memory"); - let reserved_block_bytes = block_template_overhead_bytes(net) - .checked_add(max_coinbase_bytes(&fake_coinbase_tx)) - .expect("block template byte reservation fits in memory"); - let mut remaining_block_bytes = max_block_bytes - .checked_sub(reserved_block_bytes) - .expect("the fake coinbase and block overhead fit in a block"); - let mut remaining_block_sigops = MAX_BLOCK_SIGOPS; - let mut remaining_block_unpaid_actions: u32 = BLOCK_UNPAID_ACTION_LIMIT; - - // Adjust the sigop limit based on the coinbase transaction. - remaining_block_sigops -= fake_coinbase_tx.sigops; + let mut limits = BlockTemplateLimits::initial(net, height, &fake_coinbase_tx); // > Repeat while there is any candidate transaction // > that pays at least the conventional fee: @@ -155,11 +149,7 @@ pub fn select_mempool_transactions( tx_weights, &mut selected_txs, &mempool_tx_deps, - &mut remaining_block_bytes, - &mut remaining_block_sigops, - // The number of unpaid actions is always zero for transactions that pay the - // conventional fee, so this check and limit is effectively ignored. - &mut remaining_block_unpaid_actions, + &mut limits, ); } @@ -173,9 +163,7 @@ pub fn select_mempool_transactions( tx_weights, &mut selected_txs, &mempool_tx_deps, - &mut remaining_block_bytes, - &mut remaining_block_sigops, - &mut remaining_block_unpaid_actions, + &mut limits, ); } @@ -267,20 +255,14 @@ fn checked_add_transaction_weighted_random( tx_weights: WeightedIndex, selected_txs: &mut Vec, mempool_tx_deps: &TransactionDependencies, - remaining_block_bytes: &mut usize, - remaining_block_sigops: &mut u32, - remaining_block_unpaid_actions: &mut u32, + limits: &mut BlockTemplateLimits, ) -> Option> { // > Pick one of those transactions at random with probability in direct proportion // > to its weight_ratio, and remove it from the set of candidate transactions let (new_tx_weights, candidate_tx) = choose_transaction_weighted_random(candidate_txs, tx_weights); - if !candidate_tx.try_update_block_template_limits( - remaining_block_bytes, - remaining_block_sigops, - remaining_block_unpaid_actions, - ) { + if !limits.try_add(&candidate_tx) { return new_tx_weights; } @@ -321,11 +303,7 @@ fn checked_add_transaction_weighted_random( continue; } - if !candidate_tx.try_update_block_template_limits( - remaining_block_bytes, - remaining_block_sigops, - remaining_block_unpaid_actions, - ) { + if !limits.try_add(&candidate_tx) { continue; } @@ -348,53 +326,140 @@ fn checked_add_transaction_weighted_random( new_tx_weights } -trait TryUpdateBlockLimits { - /// Checks if a transaction fits within the provided remaining block bytes, - /// sigops, and unpaid actions limits. - /// - /// Updates the limits and returns true if the transaction does fit, or - /// returns false otherwise. - fn try_update_block_template_limits( - &self, - remaining_block_bytes: &mut usize, - remaining_block_sigops: &mut u32, - remaining_block_unpaid_actions: &mut u32, - ) -> bool; +/// Tracks the remaining capacity of a block template against every limit a +/// candidate mempool transaction can exhaust: the ZIP-317 byte, sigop, and +/// unpaid-action limits, and, once ZIP 218 is active, the per-pool shielded +/// action limits and the global shielded budget. +/// +/// The shielded fields start at [`u32::MAX`] while ZIP 218 is inactive, so the +/// new limits have no effect on those templates. +struct BlockTemplateLimits { + remaining_bytes: usize, + remaining_sigops: u32, + remaining_unpaid_actions: u32, + remaining_orchard_actions: u32, + remaining_sapling_ios: u32, + remaining_sprout_joinsplits: u32, + remaining_shielded_cost: u32, } -impl TryUpdateBlockLimits for VerifiedUnminedTx { - fn try_update_block_template_limits( - &self, - remaining_block_bytes: &mut usize, - remaining_block_sigops: &mut u32, - remaining_block_unpaid_actions: &mut u32, - ) -> bool { - // > If the block template with this transaction included - // > would be within the block size limit and block sigop limit, - // > and block_unpaid_actions <= block_unpaid_action_limit, - // > add the transaction to the block template - // - // Unpaid actions are always zero for transactions that pay the conventional fee, so the - // unpaid action check always passes for those transactions. Use the full block-level sigop - // count (legacy + P2SH) so template selection cannot produce blocks that the block verifier - // would reject for exceeding `MAX_BLOCK_SIGOPS`. - let tx_block_sigops = self.block_sigop_count(); - if self.transaction.size() <= *remaining_block_bytes - && tx_block_sigops <= *remaining_block_sigops - && self.unpaid_actions <= *remaining_block_unpaid_actions - { - *remaining_block_bytes -= self.transaction.size(); - *remaining_block_sigops -= tx_block_sigops; +impl BlockTemplateLimits { + /// Returns the initial limits for a block template at `height`, with the + /// block overhead and `fake_coinbase_tx` already deducted from every + /// applicable limit. + fn initial( + network: &Network, + height: Height, + fake_coinbase_tx: &TransactionTemplate, + ) -> Self { + let coinbase: transaction::Transaction = fake_coinbase_tx + .data + .as_ref() + .zcash_deserialize_into() + .expect("a generated coinbase template is structurally valid"); + let shielded_limits = + Self::remaining_shielded_limits(network, height, coinbase.shielded_action_counts()); + + let max_block_bytes: usize = MAX_BLOCK_BYTES.try_into().expect("fits in memory"); + let reserved_block_bytes = block_template_overhead_bytes(network) + .checked_add(max_coinbase_bytes(fake_coinbase_tx)) + .expect("block template byte reservation fits in memory"); + + Self { + remaining_bytes: max_block_bytes + .checked_sub(reserved_block_bytes) + .expect("the fake coinbase and block overhead fit in a block"), + remaining_sigops: MAX_BLOCK_SIGOPS - fake_coinbase_tx.sigops, + remaining_unpaid_actions: BLOCK_UNPAID_ACTION_LIMIT, + remaining_orchard_actions: shielded_limits.orchard_actions, + remaining_sapling_ios: shielded_limits.sapling_ios, + remaining_sprout_joinsplits: shielded_limits.sprout_joinsplits, + remaining_shielded_cost: shielded_limits.cost, + } + } - // Unpaid actions are always zero for transactions that pay the conventional fee, - // so this limit always remains the same after they are added. - *remaining_block_unpaid_actions -= self.unpaid_actions; + /// Returns the ZIP 218 capacity left after the generated coinbase. + fn remaining_shielded_limits( + network: &Network, + height: Height, + coinbase: ShieldedActionCounts, + ) -> RemainingShieldedLimits { + if !NetworkUpgrade::is_zip218_active(network, height) { + return RemainingShieldedLimits { + orchard_actions: u32::MAX, + sapling_ios: u32::MAX, + sprout_joinsplits: u32::MAX, + cost: u32::MAX, + }; + } - true - } else { - false + RemainingShieldedLimits { + orchard_actions: ORCHARD_BLOCK_ACTION_LIMIT + .checked_sub(coinbase.orchard_actions) + .expect("a generated coinbase satisfies the Orchard action limit"), + sapling_ios: SAPLING_BLOCK_IO_LIMIT + .checked_sub(coinbase.sapling_ios) + .expect("a generated coinbase satisfies the Sapling I/O limit"), + sprout_joinsplits: SPROUT_BLOCK_JOINSPLIT_LIMIT + .checked_sub(coinbase.sprout_joinsplits) + .expect("a generated coinbase satisfies the Sprout JoinSplit limit"), + cost: GLOBAL_SHIELDED_BUDGET + .checked_sub(coinbase.cost()) + .expect("a generated coinbase satisfies the global shielded budget"), } } + + /// Adds `tx` to the block template and returns `true` if it fits within + /// every remaining limit. Otherwise leaves `self` unchanged and returns + /// `false`. + /// + /// > If the block template with this transaction included + /// > would be within the block size limit and block sigop limit, + /// > and block_unpaid_actions <= block_unpaid_action_limit, + /// > add the transaction to the block template + /// + /// Unpaid actions are always zero for transactions that pay the conventional + /// fee, so the unpaid action check always passes for those transactions. The + /// sigop count is the full block-level count (legacy + P2SH), so template + /// selection cannot produce blocks the block verifier would reject for + /// exceeding `MAX_BLOCK_SIGOPS`. The shielded counts come from the same + /// [`ShieldedActionCounts`](zakura_chain::transaction::ShieldedActionCounts) + /// the block verifier sums, so a template cannot exceed the ZIP 218 limits + /// either. + fn try_add(&mut self, tx: &VerifiedUnminedTx) -> bool { + let counts = tx.transaction.transaction().shielded_action_counts(); + let cost = counts.cost(); + let tx_block_sigops = tx.block_sigop_count(); + + if tx.transaction.size() > self.remaining_bytes + || tx_block_sigops > self.remaining_sigops + || tx.unpaid_actions > self.remaining_unpaid_actions + || counts.orchard_actions > self.remaining_orchard_actions + || counts.sapling_ios > self.remaining_sapling_ios + || counts.sprout_joinsplits > self.remaining_sprout_joinsplits + || cost > self.remaining_shielded_cost + { + return false; + } + + self.remaining_bytes -= tx.transaction.size(); + self.remaining_sigops -= tx_block_sigops; + self.remaining_unpaid_actions -= tx.unpaid_actions; + self.remaining_orchard_actions -= counts.orchard_actions; + self.remaining_sapling_ios -= counts.sapling_ios; + self.remaining_sprout_joinsplits -= counts.sprout_joinsplits; + self.remaining_shielded_cost -= cost; + + true + } +} + +/// ZIP 218 capacity remaining after the generated coinbase transaction. +struct RemainingShieldedLimits { + orchard_actions: u32, + sapling_ios: u32, + sprout_joinsplits: u32, + cost: u32, } /// Choose a transaction from `transactions`, using the previously set up `weighted_index`. diff --git a/crates/zakura-rpc/src/methods/types/get_block_template/zip317/tests.rs b/crates/zakura-rpc/src/methods/types/get_block_template/zip317/tests.rs index c3e6776790..4b29cff873 100644 --- a/crates/zakura-rpc/src/methods/types/get_block_template/zip317/tests.rs +++ b/crates/zakura-rpc/src/methods/types/get_block_template/zip317/tests.rs @@ -199,3 +199,150 @@ fn includes_tx_with_selected_dependencies() { "should return a dependency depth of 1 for the dependent tx" ); } + +/// Tests that block template selection respects the ZIP 218 shielded limits, so +/// a template cannot exceed a limit the block verifier enforces. +mod zip218_template_limits { + use std::sync::Arc; + + use zakura_chain::{ + parameters::{ + testnet::{ConfiguredActivationHeights, Parameters}, + Network, GLOBAL_SHIELDED_BUDGET, ORCHARD_BLOCK_ACTION_LIMIT, SAPLING_BLOCK_IO_LIMIT, + SPROUT_BLOCK_JOINSPLIT_LIMIT, + }, + transaction::{ + arbitrary::{fake_v5_with_orchard_actions, fake_v5_with_sapling_outputs}, + ShieldedActionCounts, Transaction, UnminedTx, VerifiedUnminedTx, + }, + }; + + use zcash_keys::address::Address; + use zcash_transparent::address::TransparentAddress; + + use super::{ + super::{BlockTemplateLimits, MinerParams}, + Amount, Height, TransactionTemplate, + }; + + /// A transaction that fills the Orchard limit leaves no room under the + /// global budget for a single Sapling output, even though the Sapling + /// per-pool limit is untouched. + #[test] + fn the_global_budget_bounds_selection_across_pools() { + let mut limits = nu7_template_limits(); + + let orchard_tx = verified_unmined_tx(fake_v5_with_orchard_actions( + usize::try_from(ORCHARD_BLOCK_ACTION_LIMIT).expect("the limit fits in usize"), + )); + let sapling_tx = verified_unmined_tx(fake_v5_with_sapling_outputs(1)); + + assert!( + limits.try_add(&orchard_tx), + "Orchard actions exactly at the per-pool limit fit in an empty template" + ); + assert!( + !limits.try_add(&sapling_tx), + "a Sapling output past the global budget must not be selected" + ); + } + + /// The shielded limits only bind once ZIP 218 is active, so a pre-NU7 + /// template accepts a transaction that a post-NU7 template rejects. + #[test] + fn the_shielded_limits_only_bind_after_activation() { + let network = nu7_activation_testnet(2); + let over_limit_tx = verified_unmined_tx(fake_v5_with_orchard_actions( + usize::try_from(ORCHARD_BLOCK_ACTION_LIMIT + 1).expect("the limit fits in usize"), + )); + + let mut pre_activation = template_limits(&network, Height(1)); + assert!( + pre_activation.try_add(&over_limit_tx), + "the shielded limits are inactive below the NU7 activation height" + ); + + let mut post_activation = template_limits(&network, Height(2)); + assert_eq!( + post_activation.try_add(&over_limit_tx), + !cfg!(feature = "nu7-experimental"), + "an experimental NU7 build rejects Orchard actions above the per-block limit at NU7" + ); + } + + /// A shielded coinbase output consumes the same block capacity as a + /// shielded output in any other transaction. + #[test] + fn the_coinbase_consumes_shielded_capacity() { + let network = nu7_activation_testnet(1); + let limits = BlockTemplateLimits::remaining_shielded_limits( + &network, + Height(1), + ShieldedActionCounts { + sapling_ios: 1, + ..Default::default() + }, + ); + + let expected_sapling_ios = if cfg!(feature = "nu7-experimental") { + SAPLING_BLOCK_IO_LIMIT - 1 + } else { + u32::MAX + }; + let expected_cost = if cfg!(feature = "nu7-experimental") { + GLOBAL_SHIELDED_BUDGET - 1 + } else { + u32::MAX + }; + + assert_eq!(limits.sapling_ios, expected_sapling_ios); + assert_eq!(limits.cost, expected_cost); + } + + fn template_limits(network: &Network, height: Height) -> BlockTemplateLimits { + let miner_params = + MinerParams::from(Address::from(TransparentAddress::PublicKeyHash([0x7e; 20]))); + let fake_coinbase_tx = + TransactionTemplate::new_coinbase(network, height, &miner_params, Amount::zero()) + .expect("valid coinbase transaction template"); + + BlockTemplateLimits::initial(network, height, &fake_coinbase_tx) + } + + fn nu7_template_limits() -> BlockTemplateLimits { + BlockTemplateLimits { + remaining_bytes: usize::MAX, + remaining_sigops: u32::MAX, + remaining_unpaid_actions: u32::MAX, + remaining_orchard_actions: ORCHARD_BLOCK_ACTION_LIMIT, + remaining_sapling_ios: SAPLING_BLOCK_IO_LIMIT, + remaining_sprout_joinsplits: SPROUT_BLOCK_JOINSPLIT_LIMIT, + remaining_shielded_cost: GLOBAL_SHIELDED_BUDGET, + } + } + + fn nu7_activation_testnet(nu7_activation_height: u32) -> Network { + Parameters::build() + .with_slow_start_interval(Height(0)) + .with_activation_heights(ConfiguredActivationHeights { + // The coinbase template hashes the transaction, which the + // pre-Overwinter format does not support, so activate the + // earlier upgrades from height 1. + nu5: Some(1), + nu7: Some(nu7_activation_height), + ..Default::default() + }) + .expect("activation heights are valid") + .clear_funding_streams() + .to_network() + .expect("configured testnet is valid") + } + + fn verified_unmined_tx(transaction: Arc) -> VerifiedUnminedTx { + let unmined_tx = UnminedTx::from(transaction); + let miner_fee = unmined_tx.conventional_fee(); + + VerifiedUnminedTx::new(unmined_tx, miner_fee, 0, 0, Arc::new(Vec::new())) + .expect("the fake transaction pays the conventional fee") + } +} diff --git a/crates/zakura-state/Cargo.toml b/crates/zakura-state/Cargo.toml index b1758c5db5..4d04f227b8 100644 --- a/crates/zakura-state/Cargo.toml +++ b/crates/zakura-state/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zakura-state" -version = "8.0.0" +version = "8.0.1" authors.workspace = true description = "State contextual verification and storage code for the Zakura node. Internal crate, published to support cargo install zakura" license.workspace = true @@ -17,6 +17,12 @@ categories = ["asynchronous", "caching", "cryptography::cryptocurrencies"] [features] +# Experimental candidate NU7 consensus rules, dormant until NU7 activates. +nu7-experimental = [ + "zakura-chain/nu7-experimental", + "zakura-header-chain/nu7-experimental", +] + # Production features that activate extra dependencies, or extra features in dependencies # Exposes narrow internal hooks used by benchmarks. @@ -75,8 +81,8 @@ sapling-crypto = { workspace = true } zakura-assets = { workspace = true } zakura-node-services = { path = "../zakura-node-services", version = "3.2.4" } -zakura-chain = { path = "../zakura-chain", version = "7.0.0", features = ["async-error"] } -zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.0" } +zakura-chain = { path = "../zakura-chain", version = "7.0.1", features = ["async-error"] } +zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.1" } # prod feature progress-bar howudoin = { workspace = true, optional = true } @@ -88,7 +94,7 @@ derive-getters.workspace = true derive-new.workspace = true [dev-dependencies] -zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.0", features = ["test-support"] } +zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.1", features = ["test-support"] } color-eyre = { workspace = true } once_cell = { workspace = true } @@ -108,7 +114,7 @@ orchard = { workspace = true } tokio = { workspace = true, features = ["full", "tracing", "test-util"] } -zakura-chain = { path = "../zakura-chain", version = "7.0.0", features = ["proptest-impl"] } +zakura-chain = { path = "../zakura-chain", version = "7.0.1", features = ["proptest-impl"] } zakura-test = { path = "../zakura-test/", version = "2.1.0" } [lints] diff --git a/crates/zakura-state/src/service/check/tests/vectors.rs b/crates/zakura-state/src/service/check/tests/vectors.rs index 59b871ddd7..161a474647 100644 --- a/crates/zakura-state/src/service/check/tests/vectors.rs +++ b/crates/zakura-state/src/service/check/tests/vectors.rs @@ -303,7 +303,13 @@ fn daa_context( let target_spacing = NetworkUpgrade::target_spacing_for_height(network, candidate_height); let difficulty = network.target_difficulty_limit().to_compact(); - (0..difficulty::POW_ADJUSTMENT_BLOCK_SPAN) + // The difficulty context spans the whole chain below the adjustment span, + // and the span itself above it. + let context_len = usize::try_from(candidate_height.0) + .expect("test candidate height fits in usize") + .min(difficulty::POW_ADJUSTMENT_BLOCK_SPAN); + + (0..context_len) .map(|offset| { let offset = i32::try_from(offset + 1).expect("test offset fits in i32"); (difficulty, candidate_time - target_spacing * offset) diff --git a/crates/zakura-state/src/service/finalized_state/header_chain/migration.rs b/crates/zakura-state/src/service/finalized_state/header_chain/migration.rs index 4771c32ebb..df26982954 100644 --- a/crates/zakura-state/src/service/finalized_state/header_chain/migration.rs +++ b/crates/zakura-state/src/service/finalized_state/header_chain/migration.rs @@ -254,6 +254,82 @@ impl HeaderChainStore { Ok(true) } + /// Add validation context that an older build did not retain. + /// + /// The authenticated full-state header index supplies the additional rows. + /// This step runs before the startup audit because that audit requires the + /// complete context for the current build. + pub(in crate::service) fn backfill_validation_context( + &self, + source: &ZakuraDb, + ) -> Result { + let _writer = self + .writer + .lock() + .map_err(|_| HeaderChainStoreError::WriterPoisoned)?; + let metadata = self + .metadata_row()? + .ok_or(HeaderChainStoreError::Incoherent( + "validation-context backfill requires initialized metadata", + ))?; + if metadata.mode != EngineMode::Integrated { + return Ok(0); + } + + let anchor = metadata.frontiers.finalized; + let (_, anchor_header) = finalized_header_by_height(source, anchor.height) + .filter(|(hash, header)| *hash == anchor.hash && header.hash() == anchor.hash) + .ok_or(HeaderChainInitializationError::AnchorMismatch)?; + let expected = validation_context(source, anchor, anchor_header.previous_block_hash)?; + + let mut retained = Vec::new(); + self.audit_snapshot() + .map_err(HeaderChainStoreError::Store)? + .visit_validation_context_records( + zakura_header_chain::RowLimit::new( + zakura_header_chain::POW_PREDECESSOR_CONTEXT_SPAN, + ), + &mut |record| { + retained.push(record); + Ok(()) + }, + ) + .map_err(HeaderChainStoreError::Store)?; + retained.sort_unstable_by_key(|record| record.height); + + if retained.len() >= expected.len() { + return Ok(0); + } + + let expected_suffix = &expected[expected.len() - retained.len()..]; + if !retained + .iter() + .zip(expected_suffix) + .all(|(retained, expected)| { + retained.height == expected.height && retained.header == expected.header + }) + { + return Err(HeaderChainStoreError::Incoherent( + "retained validation context is not an authenticated suffix", + ) + .into()); + } + + let missing = expected.len() - retained.len(); + let mut batch = DiskWriteBatch::new(); + for context in expected.into_iter().take(missing) { + self.put_value( + &mut batch, + HEADER_VALIDATION_CONTEXT, + context.header.hash().0, + &context, + )?; + } + self.db.write(batch)?; + + Ok(missing) + } + fn stage_v1_aux_deliveries( &self, config: &EngineConfig, @@ -824,7 +900,9 @@ fn linked_validation_context( ) -> Result, HeaderChainInitializationError> { let mut contexts = Vec::new(); let mut height = anchor.height; - for _ in 0..27 { + // The recovery audit requires exactly the retained predecessor span below + // the anchor, which widens with the difficulty averaging window. + for _ in 0..zakura_header_chain::POW_PREDECESSOR_CONTEXT_SPAN { let Ok(previous) = height.previous() else { break; }; @@ -878,10 +956,14 @@ mod tests { } #[test] - fn later_anchor_predecessor_context_has_exact_one_to_twenty_eight_boundary() { - let headers = linked_headers(30); + fn later_anchor_predecessor_context_has_the_exact_span_boundary() { + let predecessor_span = zakura_header_chain::POW_PREDECESSOR_CONTEXT_SPAN; + let span_bound = + u32::try_from(predecessor_span).expect("the retained predecessor span fits in u32"); + let chain_len = span_bound + 3; + let headers = linked_headers(chain_len); - for anchor_height in 0..=29 { + for anchor_height in 0..chain_len { let anchor_index = usize::try_from(anchor_height).expect("the test height fits"); let anchor_header = &headers[anchor_index]; let anchor = Frontier::new(block::Height(anchor_height), anchor_header.hash()); @@ -894,12 +976,13 @@ mod tests { .expect("the exact backward-linked context is authenticated"); let expected_predecessors = - usize::try_from(anchor_height.min(27)).expect("the bound fits in usize"); + usize::try_from(anchor_height.min(span_bound)).expect("the bound fits in usize"); assert_eq!(contexts.len(), expected_predecessors); assert_eq!( contexts.len() + 1, - usize::try_from((anchor_height + 1).min(28)).expect("the bound fits in usize"), - "the anchor plus predecessor facts has the exact one-to-28-header boundary" + usize::try_from((anchor_height + 1).min(span_bound + 1)) + .expect("the bound fits in usize"), + "the anchor plus its predecessor facts spans the retained context exactly" ); if contexts.is_empty() { continue; diff --git a/crates/zakura-state/src/service/finalized_state/header_chain/tests/runtime.rs b/crates/zakura-state/src/service/finalized_state/header_chain/tests/runtime.rs index 2cc12d0680..a43caa68f3 100644 --- a/crates/zakura-state/src/service/finalized_state/header_chain/tests/runtime.rs +++ b/crates/zakura-state/src/service/finalized_state/header_chain/tests/runtime.rs @@ -9,9 +9,15 @@ fn atomic_finality_context_can_use_a_newly_staged_anchor_path() { .initialize(metadata, anchor.clone()) .expect("the empty schema initializes"); + // Stage one node past the retained predecessor span, so the context cap + // binds whatever difficulty averaging window this build uses. + let predecessor_span = zakura_header_chain::POW_PREDECESSOR_CONTEXT_SPAN; + let staged_len = + u32::try_from(predecessor_span + 1).expect("the retained predecessor span fits in u32"); + let mut nodes = Vec::new(); let mut parent = anchor; - for height in 1..=28 { + for height in 1..=staged_len { let mut header = *parent.header; header.previous_block_hash = parent.hash; header.time += chrono::Duration::seconds(1); @@ -40,14 +46,14 @@ fn atomic_finality_context_can_use_a_newly_staged_anchor_path() { let staged: HashMap<_, _> = nodes.iter().map(|node| (node.hash, node)).collect(); let contexts = authenticated_context_headers(&store, parent.hash, Some(&staged)) .expect("the atomic batch can authenticate context from its staged node overlay"); - assert_eq!(contexts.len(), 27); + assert_eq!(contexts.len(), predecessor_span); assert_eq!( contexts.first().map(|context| context.height), Some(block::Height(1)) ); assert_eq!( contexts.last().map(|context| context.height), - Some(block::Height(27)) + Some(block::Height(staged_len - 1)) ); assert_eq!( parent.header.previous_block_hash, diff --git a/crates/zakura-state/src/service/finalized_state/zakura_db/block/tests/migration.rs b/crates/zakura-state/src/service/finalized_state/zakura_db/block/tests/migration.rs index bb2cec2f16..bb6715e396 100644 --- a/crates/zakura-state/src/service/finalized_state/zakura_db/block/tests/migration.rs +++ b/crates/zakura-state/src/service/finalized_state/zakura_db/block/tests/migration.rs @@ -32,6 +32,9 @@ use crate::{ Config, }; +#[cfg(feature = "nu7-experimental")] +use crate::service::finalized_state::HEADER_VALIDATION_CONTEXT; + fn engine_config(network: Network, genesis: &Arc) -> EngineConfig { let frontier = Frontier::new(Height(0), genesis.hash()); EngineConfig::new( @@ -206,6 +209,102 @@ fn predecessor_overlay_is_atomically_replaced_from_finalized_state() { ); } +#[test] +#[cfg(feature = "nu7-experimental")] +fn zip218_build_backfills_an_existing_validation_context_before_startup() { + let _init_guard = zakura_test::init(); + let network = Network::Mainnet; + let genesis = mainnet_block(0); + let state = state_with_genesis_config(&network, genesis.clone(), Config::ephemeral()); + let predecessor_span = zakura_header_chain::POW_PREDECESSOR_CONTEXT_SPAN; + let chain_tip = u32::try_from(predecessor_span + 1) + .expect("the validation context span fits in a block height"); + + let header_cf = state + .db + .cf_handle("block_header_by_height") + .expect("the full-state header column exists"); + let hash_cf = state + .db + .cf_handle("hash_by_height") + .expect("the full-state hash column exists"); + let height_cf = state + .db + .cf_handle("height_by_hash") + .expect("the full-state reverse hash column exists"); + let mut headers = vec![genesis.header.clone()]; + let mut full_state = DiskWriteBatch::new(); + for height in 1..=chain_tip { + let previous = headers + .last() + .expect("the synthetic chain starts at genesis"); + let mut header = **previous; + header.previous_block_hash = previous.hash(); + header.time += chrono::Duration::seconds(1); + header.nonce.0[0] = + u8::try_from(height).expect("the synthetic chain is shorter than 256 blocks"); + let header = Arc::new(header); + let hash = header.hash(); + let height = Height(height); + full_state.zs_insert(&header_cf, height, &header); + full_state.zs_insert(&hash_cf, height, hash); + full_state.zs_insert(&height_cf, hash, height); + headers.push(header); + } + state + .db + .write(full_state) + .expect("the synthetic finalized header chain writes"); + + let config = engine_config(network, &genesis); + let (runtime, report) = initialize_header_chain_reconciled(&state, &config, Vec::new()) + .expect("the complete validation context initializes"); + assert_eq!(report.validation_context_rows, predecessor_span); + drop(runtime); + + let store = HeaderChainStore::new(state.header_chain_disk_db()); + let mut contexts = Vec::new(); + store + .audit_snapshot() + .expect("the initialized store has an audit snapshot") + .visit_validation_context_records(RowLimit::new(predecessor_span), &mut |record| { + contexts.push(record); + Ok(()) + }) + .expect("the validation context rows decode"); + contexts.sort_unstable_by_key(|record| record.height); + + // A build without ZIP 218 retained 17 averaging-window headers plus 10 + // additional median-time headers below the finalized anchor. + let old_predecessor_span = 27; + let mut downgrade = DiskWriteBatch::new(); + let context_cf = state + .db + .cf_handle(HEADER_VALIDATION_CONTEXT) + .expect("the validation context column exists"); + for context in contexts + .iter() + .take(predecessor_span - old_predecessor_span) + { + downgrade.zs_delete(&context_cf, context.header.hash()); + } + state + .db + .write(downgrade) + .expect("the pre-ZIP 218 context fixture writes"); + + assert_eq!( + store + .backfill_validation_context(&state) + .expect("authenticated full state backfills the wider context"), + predecessor_span - old_predecessor_span, + ); + let (_, startup) = store + .startup(&config) + .expect("startup accepts the backfilled validation context"); + assert!(startup.publication_allowed); +} + #[test] fn predecessor_overlay_is_preserved_when_full_state_authentication_fails() { let _init_guard = zakura_test::init(); diff --git a/crates/zakura-state/src/service/write.rs b/crates/zakura-state/src/service/write.rs index c55644d4aa..e7d89a1171 100644 --- a/crates/zakura-state/src/service/write.rs +++ b/crates/zakura-state/src/service/write.rs @@ -539,6 +539,7 @@ impl HeaderChainWriter { let store = HeaderChainStore::new(finalized_state.db.header_chain_disk_db()); store.migrate_to_current(&config)?; let runtime = if store.is_initialized()? { + store.backfill_validation_context(&finalized_state.db)?; let persisted_finalized = store.snapshot()?.frontiers.finalized; let (full_state_height, full_state_hash) = finalized_state .db diff --git a/crates/zakurad/Cargo.toml b/crates/zakurad/Cargo.toml index 8f6026955b..04dfbf6bc5 100644 --- a/crates/zakurad/Cargo.toml +++ b/crates/zakurad/Cargo.toml @@ -74,6 +74,16 @@ features = [ ] [features] + +# Experimental candidate NU7 consensus rules, dormant until NU7 activates. +nu7-experimental = [ + "zakura-chain/nu7-experimental", + "zakura-consensus/nu7-experimental", + "zakura-state/nu7-experimental", + "zakura-rpc/nu7-experimental", + "zakura-header-chain/nu7-experimental", +] + # In release builds, don't compile debug logging code, to improve performance. default-release-binaries = ["release_max_level_info", "progress-bar", "prometheus", "sentry", "opentelemetry"] @@ -83,11 +93,6 @@ default = ["default-release-binaries"] # Production features that activate extra dependencies, or extra features in dependencies -# Experimental candidate NU7 consensus rules, dormant until NU7 activates. -nu7-experimental = [ - "zakura-consensus/nu7-experimental", -] - # Indexer support indexer = ["zakura-state/indexer", "zakura-rpc/indexer"] @@ -173,14 +178,14 @@ tokio-console = ["console-subscriber"] comparison-interpreter = ["zakura-script/comparison-interpreter"] [dependencies] -zakura-chain = { path = "../zakura-chain", version = "7.0.0" } -zakura-consensus = { path = "../zakura-consensus", version = "8.0.0" } -zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.0" } +zakura-chain = { path = "../zakura-chain", version = "7.0.1" } +zakura-consensus = { path = "../zakura-consensus", version = "9.0.0" } +zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.1" } zakura-jsonl-trace = { path = "../zakura-jsonl-trace", version = "1.2.0" } zakura-network = { path = "../zakura-network", version = "8.0.0" } zakura-node-services = { path = "../zakura-node-services", version = "3.2.4", features = ["rpc-client"] } zakura-rpc = { path = "../zakura-rpc", version = "10.0.1-rc1" } -zakura-state = { path = "../zakura-state", version = "8.0.0" } +zakura-state = { path = "../zakura-state", version = "8.0.1" } # zakura-script is not used directly, but we list it here to enable the # "comparison-interpreter" feature. (Feature unification will take care of # enabling it in the other imports of zcash-script.) @@ -310,11 +315,11 @@ tonic-prost = { workspace = true } proptest = { workspace = true } proptest-derive = { workspace = true } -zakura-chain = { path = "../zakura-chain", version = "7.0.0", features = ["proptest-impl"] } -zakura-consensus = { path = "../zakura-consensus", version = "8.0.0", features = ["proptest-impl"] } -zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.0", features = ["test-support"] } +zakura-chain = { path = "../zakura-chain", version = "7.0.1", features = ["proptest-impl"] } +zakura-consensus = { path = "../zakura-consensus", version = "9.0.0", features = ["proptest-impl"] } +zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.1", features = ["test-support"] } zakura-network = { path = "../zakura-network", version = "8.0.0", features = ["proptest-impl", "zakura-testkit"] } -zakura-state = { path = "../zakura-state", version = "8.0.0", features = ["proptest-impl"] } +zakura-state = { path = "../zakura-state", version = "8.0.1", features = ["proptest-impl"] } zakura-rpc = { path = "../zakura-rpc", version = "10.0.1-rc1", features = ["proptest-impl"] } zakura-test = { path = "../zakura-test", version = "2.1.0" } diff --git a/docs/changelog/unreleased/851.md b/docs/changelog/unreleased/851.md new file mode 100644 index 0000000000..09023592d7 --- /dev/null +++ b/docs/changelog/unreleased/851.md @@ -0,0 +1,18 @@ +## Added + +- ZIP 218 "25-second Block Target Spacing", behind the off-by-default + `nu7-experimental` + build feature: a 25 second block target spacing, a 102-block difficulty + averaging window, a block subsidy divided by the spacing ratio, and per-block + shielded action limits, all applied from NU7 activation onwards. The rules + stay dormant until NU7 has an activation height on the configured network, so + a build with this feature follows today's consensus on Mainnet and Testnet + ([#851](https://github.com/zakura-core/zakura/pull/851)). + +## Changed + +- ZIP 2003 "Disallow version 4 transactions": The off-by-default + `nu7-experimental` feature makes Zakura reject version 4 transactions from + NU7 activation. This deprecates new Sprout transactions without exposing a + separate consensus configuration + ([#856](https://github.com/zakura-core/zakura/pull/856)).