diff --git a/zebra-chain/src/orchard/orchard_flavor_ext.rs b/zebra-chain/src/orchard/orchard_flavor_ext.rs index 6ad05abd889..32d887472f4 100644 --- a/zebra-chain/src/orchard/orchard_flavor_ext.rs +++ b/zebra-chain/src/orchard/orchard_flavor_ext.rs @@ -9,7 +9,10 @@ use proptest_derive::Arbitrary; use orchard::{note_encryption::OrchardDomainCommon, orchard_flavor}; -use crate::serialization::{ZcashDeserialize, ZcashSerialize}; +use crate::{ + orchard_zsa, + serialization::{ZcashDeserialize, ZcashSerialize}, +}; #[cfg(feature = "tx-v6")] use crate::orchard_zsa::{Burn, NoBurn}; @@ -50,7 +53,13 @@ pub trait OrchardFlavorExt: Clone + Debug { /// A type representing a burn field for this protocol version. #[cfg(feature = "tx-v6")] - type BurnType: Clone + Debug + Default + ZcashDeserialize + ZcashSerialize + TestArbitrary; + type BurnType: Clone + + Debug + + Default + + ZcashDeserialize + + ZcashSerialize + + TestArbitrary + + AsRef<[orchard_zsa::BurnItem]>; } /// A structure representing a tag for Orchard protocol variant used for the transaction version `V5`. diff --git a/zebra-chain/src/orchard_zsa.rs b/zebra-chain/src/orchard_zsa.rs index 91445ebf949..2b3fafb1381 100644 --- a/zebra-chain/src/orchard_zsa.rs +++ b/zebra-chain/src/orchard_zsa.rs @@ -4,11 +4,14 @@ #[cfg(any(test, feature = "proptest-impl"))] pub(crate) mod arbitrary; -#[cfg(test)] -mod tests; +#[cfg(any(test, feature = "proptest-impl"))] +pub mod tests; +mod asset_state; mod burn; mod issuance; -pub(crate) use burn::{Burn, NoBurn}; +pub(crate) use burn::{Burn, BurnItem, NoBurn}; pub(crate) use issuance::IssueData; + +pub use asset_state::{AssetBase, AssetState, AssetStateChange, IssuedAssets, IssuedAssetsChange}; diff --git a/zebra-chain/src/orchard_zsa/asset_state.rs b/zebra-chain/src/orchard_zsa/asset_state.rs new file mode 100644 index 00000000000..e8ebdf57109 --- /dev/null +++ b/zebra-chain/src/orchard_zsa/asset_state.rs @@ -0,0 +1,372 @@ +//! Defines and implements the issued asset state types + +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use orchard::issuance::IssueAction; +pub use orchard::note::AssetBase; + +use crate::transaction::Transaction; + +use super::BurnItem; + +/// The circulating supply and whether that supply has been finalized. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct AssetState { + /// Indicates whether the asset is finalized such that no more of it can be issued. + pub is_finalized: bool, + + /// The circulating supply that has been issued for an asset. + pub total_supply: u64, +} + +/// A change to apply to the issued assets map. +// TODO: Reference ZIP +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct AssetStateChange { + /// Whether the asset should be finalized such that no more of it can be issued. + pub should_finalize: bool, + /// Whether the asset should be finalized such that no more of it can be issued. + pub includes_issuance: bool, + /// The change in supply from newly issued assets or burned assets, if any. + pub supply_change: SupplyChange, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +/// An asset supply change to apply to the issued assets map. +pub enum SupplyChange { + /// An issuance that should increase the total supply of an asset + Issuance(u64), + + /// A burn that should reduce the total supply of an asset. + Burn(u64), +} + +impl Default for SupplyChange { + fn default() -> Self { + Self::Issuance(0) + } +} + +impl SupplyChange { + /// Applies `self` to a provided `total_supply` of an asset. + /// + /// Returns the updated total supply after the [`SupplyChange`] has been applied. + fn apply_to(self, total_supply: u64) -> Option { + match self { + SupplyChange::Issuance(amount) => total_supply.checked_add(amount), + SupplyChange::Burn(amount) => total_supply.checked_sub(amount), + } + } + + /// Returns the [`SupplyChange`] amount as an [`i128`] where burned amounts + /// are negative. + fn as_i128(self) -> i128 { + match self { + SupplyChange::Issuance(amount) => i128::from(amount), + SupplyChange::Burn(amount) => -i128::from(amount), + } + } + + /// Attempts to add another supply change to `self`. + /// + /// Returns true if successful or false if the result would be invalid. + fn add(&mut self, rhs: Self) -> bool { + if let Some(result) = self + .as_i128() + .checked_add(rhs.as_i128()) + .and_then(|signed| match signed { + // Burn amounts MUST not be 0 + // TODO: Reference ZIP + 0.. => signed.try_into().ok().map(Self::Issuance), + ..0 => signed.try_into().ok().map(Self::Burn), + }) + { + *self = result; + true + } else { + false + } + } + + /// Returns true if this [`SupplyChange`] is an issuance. + pub fn is_issuance(&self) -> bool { + matches!(self, SupplyChange::Issuance(_)) + } +} + +impl std::ops::Neg for SupplyChange { + type Output = Self; + + fn neg(self) -> Self::Output { + match self { + Self::Issuance(amount) => Self::Burn(amount), + Self::Burn(amount) => Self::Issuance(amount), + } + } +} + +impl AssetState { + /// Updates and returns self with the provided [`AssetStateChange`] if + /// the change is valid, or returns None otherwise. + pub fn apply_change(self, change: AssetStateChange) -> Option { + self.apply_finalization(change)?.apply_supply_change(change) + } + + /// Updates the `is_finalized` field on `self` if the change is valid and + /// returns `self`, or returns None otherwise. + fn apply_finalization(mut self, change: AssetStateChange) -> Option { + if self.is_finalized && change.includes_issuance { + None + } else { + self.is_finalized |= change.should_finalize; + Some(self) + } + } + + /// Updates the `supply_change` field on `self` if the change is valid and + /// returns `self`, or returns None otherwise. + fn apply_supply_change(mut self, change: AssetStateChange) -> Option { + self.total_supply = change.supply_change.apply_to(self.total_supply)?; + Some(self) + } + + /// Reverts the provided [`AssetStateChange`]. + pub fn revert_change(&mut self, change: AssetStateChange) { + *self = self + .revert_finalization(change.should_finalize) + .revert_supply_change(change) + .expect("reverted change should be validated"); + } + + /// Reverts the changes to `is_finalized` from the provied [`AssetStateChange`]. + fn revert_finalization(mut self, should_finalize: bool) -> Self { + self.is_finalized &= !should_finalize; + self + } + + /// Reverts the changes to `supply_change` from the provied [`AssetStateChange`]. + fn revert_supply_change(mut self, change: AssetStateChange) -> Option { + self.total_supply = (-change.supply_change).apply_to(self.total_supply)?; + Some(self) + } +} + +impl From> for IssuedAssets { + fn from(issued_assets: HashMap) -> Self { + Self(issued_assets) + } +} + +impl AssetStateChange { + /// Creates a new [`AssetStateChange`] from an asset base, supply change, and + /// `should_finalize` flag. + fn new( + asset_base: AssetBase, + supply_change: SupplyChange, + should_finalize: bool, + ) -> (AssetBase, Self) { + ( + asset_base, + Self { + should_finalize, + includes_issuance: supply_change.is_issuance(), + supply_change, + }, + ) + } + + /// Accepts a transaction and returns an iterator of asset bases and issued asset state changes + /// that should be applied to those asset bases when committing the transaction to the chain state. + fn from_transaction(tx: &Arc) -> impl Iterator + '_ { + Self::from_burns(tx.orchard_burns()) + .chain(Self::from_issue_actions(tx.orchard_issue_actions())) + } + + /// Accepts an iterator of [`IssueAction`]s and returns an iterator of asset bases and issued asset state changes + /// that should be applied to those asset bases when committing the provided issue actions to the chain state. + fn from_issue_actions<'a>( + actions: impl Iterator + 'a, + ) -> impl Iterator + 'a { + actions.flat_map(Self::from_issue_action) + } + + /// Accepts an [`IssueAction`] and returns an iterator of asset bases and issued asset state changes + /// that should be applied to those asset bases when committing the provided issue action to the chain state. + fn from_issue_action(action: &IssueAction) -> impl Iterator + '_ { + let supply_changes = Self::from_notes(action.notes()); + let finalize_changes = action + .is_finalized() + .then(|| { + action + .notes() + .iter() + .map(orchard::Note::asset) + .collect::>() + }) + .unwrap_or_default() + .into_iter() + .map(|asset_base| Self::new(asset_base, SupplyChange::Issuance(0), true)); + + supply_changes.chain(finalize_changes) + } + + /// Accepts an iterator of [`orchard::Note`]s and returns an iterator of asset bases and issued asset state changes + /// that should be applied to those asset bases when committing the provided orchard notes to the chain state. + fn from_notes(notes: &[orchard::Note]) -> impl Iterator + '_ { + notes.iter().copied().map(Self::from_note) + } + + /// Accepts an [`orchard::Note`] and returns an iterator of asset bases and issued asset state changes + /// that should be applied to those asset bases when committing the provided orchard note to the chain state. + fn from_note(note: orchard::Note) -> (AssetBase, Self) { + Self::new( + note.asset(), + SupplyChange::Issuance(note.value().inner()), + false, + ) + } + + /// Accepts an iterator of [`BurnItem`]s and returns an iterator of asset bases and issued asset state changes + /// that should be applied to those asset bases when committing the provided asset burns to the chain state. + fn from_burns(burns: &[BurnItem]) -> impl Iterator + '_ { + burns.iter().map(Self::from_burn) + } + + /// Accepts an [`BurnItem`] and returns an iterator of asset bases and issued asset state changes + /// that should be applied to those asset bases when committing the provided burn to the chain state. + fn from_burn(burn: &BurnItem) -> (AssetBase, Self) { + Self::new(burn.asset(), SupplyChange::Burn(burn.amount()), false) + } + + /// Updates and returns self with the provided [`AssetStateChange`] if + /// the change is valid, or returns None otherwise. + pub fn apply_change(&mut self, change: AssetStateChange) -> bool { + if self.should_finalize && change.includes_issuance { + return false; + } + self.should_finalize |= change.should_finalize; + self.includes_issuance |= change.includes_issuance; + self.supply_change.add(change.supply_change) + } +} + +/// An map of issued asset states by asset base. +// TODO: Reference ZIP +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IssuedAssets(HashMap); + +impl IssuedAssets { + /// Creates a new [`IssuedAssets`]. + pub fn new() -> Self { + Self(HashMap::new()) + } + + /// Returns an iterator of the inner HashMap. + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + /// Extends inner [`HashMap`] with updated asset states from the provided iterator + fn extend<'a>(&mut self, issued_assets: impl Iterator + 'a) { + self.0.extend(issued_assets); + } +} + +impl IntoIterator for IssuedAssets { + type Item = (AssetBase, AssetState); + + type IntoIter = std::collections::hash_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +/// A map of changes to apply to the issued assets map. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IssuedAssetsChange(HashMap); + +impl IssuedAssetsChange { + /// Creates a new [`IssuedAssetsChange`]. + fn new() -> Self { + Self(HashMap::new()) + } + + /// Applies changes in the provided iterator to an [`IssuedAssetsChange`]. + fn update<'a>( + &mut self, + changes: impl Iterator + 'a, + ) -> bool { + for (asset_base, change) in changes { + if !self.0.entry(asset_base).or_default().apply_change(change) { + return false; + } + } + + true + } + + /// Accepts a [`Arc`]. + /// + /// Returns an [`IssuedAssetsChange`] representing all of the changes to the issued assets + /// map that should be applied for the provided transaction, or `None` if the change would be invalid. + pub fn from_transaction(transaction: &Arc) -> Option { + let mut issued_assets_change = Self::new(); + + if !issued_assets_change.update(AssetStateChange::from_transaction(transaction)) { + return None; + } + + Some(issued_assets_change) + } + + /// Accepts a slice of [`Arc`]s. + /// + /// Returns an [`IssuedAssetsChange`] representing all of the changes to the issued assets + /// map that should be applied for the provided transactions. + pub fn from_transactions(transactions: &[Arc]) -> Option> { + transactions.iter().map(Self::from_transaction).collect() + } + + /// Consumes self and accepts a closure for looking up previous asset states. + /// + /// Applies changes in self to the previous asset state. + /// + /// Returns an [`IssuedAssets`] with the updated asset states. + pub fn apply_with(self, f: impl Fn(AssetBase) -> AssetState) -> IssuedAssets { + let mut issued_assets = IssuedAssets::new(); + + issued_assets.extend(self.0.into_iter().map(|(asset_base, change)| { + ( + asset_base, + f(asset_base) + .apply_change(change) + .expect("must be valid change"), + ) + })); + + issued_assets + } + + /// Iterates over the inner [`HashMap`] of asset bases and state changes. + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().map(|(&base, &state)| (base, state)) + } +} + +impl std::ops::Add for IssuedAssetsChange { + type Output = Self; + + fn add(mut self, mut rhs: Self) -> Self { + if self.0.len() > rhs.0.len() { + self.update(rhs.0.into_iter()); + self + } else { + rhs.update(self.0.into_iter()); + rhs + } + } +} diff --git a/zebra-chain/src/orchard_zsa/burn.rs b/zebra-chain/src/orchard_zsa/burn.rs index 0e0c007709b..0b15650568b 100644 --- a/zebra-chain/src/orchard_zsa/burn.rs +++ b/zebra-chain/src/orchard_zsa/burn.rs @@ -3,7 +3,6 @@ use std::io; use crate::{ - amount::Amount, block::MAX_BLOCK_BYTES, serialization::{ ReadZcashExt, SerializationError, TrustedPreallocate, ZcashDeserialize, ZcashSerialize, @@ -35,15 +34,27 @@ const AMOUNT_SIZE: u64 = 8; const BURN_ITEM_SIZE: u64 = ASSET_BASE_SIZE + AMOUNT_SIZE; /// Orchard ZSA burn item. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BurnItem(AssetBase, Amount); +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct BurnItem(AssetBase, u64); + +impl BurnItem { + /// Returns [`AssetBase`] being burned. + pub fn asset(&self) -> AssetBase { + self.0 + } + + /// Returns [`u64`] representing amount being burned. + pub fn amount(&self) -> u64 { + self.1 + } +} // Convert from burn item type used in `orchard` crate impl TryFrom<(AssetBase, NoteValue)> for BurnItem { type Error = crate::amount::Error; fn try_from(item: (AssetBase, NoteValue)) -> Result { - Ok(Self(item.0, item.1.inner().try_into()?)) + Ok(Self(item.0, item.1.inner())) } } @@ -52,7 +63,7 @@ impl ZcashSerialize for BurnItem { let BurnItem(asset_base, amount) = self; asset_base.zcash_serialize(&mut writer)?; - amount.zcash_serialize(&mut writer)?; + writer.write_all(&amount.to_be_bytes())?; Ok(()) } @@ -60,9 +71,11 @@ impl ZcashSerialize for BurnItem { impl ZcashDeserialize for BurnItem { fn zcash_deserialize(mut reader: R) -> Result { + let mut amount_bytes = [0; 8]; + reader.read_exact(&mut amount_bytes)?; Ok(Self( AssetBase::zcash_deserialize(&mut reader)?, - Amount::zcash_deserialize(&mut reader)?, + u64::from_be_bytes(amount_bytes), )) } } @@ -92,7 +105,7 @@ impl<'de> serde::Deserialize<'de> for BurnItem { D: serde::Deserializer<'de>, { // FIXME: consider another implementation (explicit specifying of [u8; 32] may not look perfect) - let (asset_base_bytes, amount) = <([u8; 32], Amount)>::deserialize(deserializer)?; + let (asset_base_bytes, amount) = <([u8; 32], u64)>::deserialize(deserializer)?; // FIXME: return custom error with a meaningful description? Ok(BurnItem( // FIXME: duplicates the body of AssetBase::zcash_deserialize? @@ -109,6 +122,12 @@ impl<'de> serde::Deserialize<'de> for BurnItem { #[derive(Default, Clone, Debug, PartialEq, Eq, Serialize)] pub struct NoBurn; +impl AsRef<[BurnItem]> for NoBurn { + fn as_ref(&self) -> &[BurnItem] { + &[] + } +} + impl ZcashSerialize for NoBurn { fn zcash_serialize(&self, mut _writer: W) -> Result<(), io::Error> { Ok(()) @@ -131,6 +150,12 @@ impl From> for Burn { } } +impl AsRef<[BurnItem]> for Burn { + fn as_ref(&self) -> &[BurnItem] { + &self.0 + } +} + impl ZcashSerialize for Burn { fn zcash_serialize(&self, writer: W) -> Result<(), io::Error> { self.0.zcash_serialize(writer) diff --git a/zebra-chain/src/orchard_zsa/issuance.rs b/zebra-chain/src/orchard_zsa/issuance.rs index 4a3cb968926..966dd85938d 100644 --- a/zebra-chain/src/orchard_zsa/issuance.rs +++ b/zebra-chain/src/orchard_zsa/issuance.rs @@ -7,6 +7,7 @@ use halo2::pasta::pallas; // For pallas::Base::from_repr only use group::ff::PrimeField; +use nonempty::NonEmpty; use zcash_primitives::transaction::components::issuance::{read_v6_bundle, write_v6_bundle}; use orchard::{ @@ -53,6 +54,11 @@ impl IssueData { }) }) } + + /// Returns issuance actions + pub fn actions(&self) -> &NonEmpty { + self.0.actions() + } } // Sizes of the serialized values for types in bytes (used for TrustedPreallocate impls) diff --git a/zebra-chain/src/orchard_zsa/tests.rs b/zebra-chain/src/orchard_zsa/tests.rs index a9301a7461e..0f61d2fcdd8 100644 --- a/zebra-chain/src/orchard_zsa/tests.rs +++ b/zebra-chain/src/orchard_zsa/tests.rs @@ -1,2 +1,4 @@ +#[cfg(test)] mod issuance; -mod vectors; + +pub mod vectors; diff --git a/zebra-chain/src/orchard_zsa/tests/issuance.rs b/zebra-chain/src/orchard_zsa/tests/issuance.rs index 9dc90d881c7..6eb0c586641 100644 --- a/zebra-chain/src/orchard_zsa/tests/issuance.rs +++ b/zebra-chain/src/orchard_zsa/tests/issuance.rs @@ -1,11 +1,17 @@ -use crate::{block::Block, serialization::ZcashDeserialize, transaction::Transaction}; +use crate::{ + block::Block, orchard_zsa::IssuedAssetsChange, serialization::ZcashDeserialize, + transaction::Transaction, +}; use super::vectors::BLOCKS; #[test] fn issuance_block() { let issuance_block = - Block::zcash_deserialize(BLOCKS[0].as_ref()).expect("issuance block should deserialize"); + Block::zcash_deserialize(BLOCKS[0]).expect("issuance block should deserialize"); + + IssuedAssetsChange::from_transactions(&issuance_block.transactions) + .expect("issuance in block should be valid"); for transaction in issuance_block.transactions { if let Transaction::V6 { diff --git a/zebra-chain/src/orchard_zsa/tests/vectors.rs b/zebra-chain/src/orchard_zsa/tests/vectors.rs index d5664e50b19..2812ee86c35 100644 --- a/zebra-chain/src/orchard_zsa/tests/vectors.rs +++ b/zebra-chain/src/orchard_zsa/tests/vectors.rs @@ -1,3 +1,19 @@ mod blocks; +use std::sync::Arc; + pub(crate) use blocks::BLOCKS; +use itertools::Itertools; + +use crate::{block::Block, serialization::ZcashDeserializeInto}; + +// TODO: Move this to zebra-test. +pub fn valid_issuance_blocks() -> Vec> { + BLOCKS + .iter() + .copied() + .map(ZcashDeserializeInto::zcash_deserialize_into) + .map(|result| result.map(Arc::new)) + .try_collect() + .expect("hard-coded block data must deserialize successfully") +} diff --git a/zebra-chain/src/transaction.rs b/zebra-chain/src/transaction.rs index 737253d6eab..c72002c4b76 100644 --- a/zebra-chain/src/transaction.rs +++ b/zebra-chain/src/transaction.rs @@ -78,6 +78,31 @@ macro_rules! orchard_shielded_data_iter { }; } +macro_rules! orchard_shielded_data_map { + ($self:expr, $mapper:expr, $mapper2:expr) => { + match $self { + Transaction::V5 { + orchard_shielded_data: Some(shielded_data), + .. + } => $mapper(shielded_data), + + #[cfg(feature = "tx-v6")] + Transaction::V6 { + orchard_shielded_data: Some(shielded_data), + .. + } => $mapper2(shielded_data), + + // No Orchard shielded data + Transaction::V1 { .. } + | Transaction::V2 { .. } + | Transaction::V3 { .. } + | Transaction::V4 { .. } + | Transaction::V5 { .. } + | Transaction::V6 { .. } => &[], + } + }; +} + // FIXME: doc this // Move down macro_rules! orchard_shielded_data_field { @@ -1071,6 +1096,45 @@ impl Transaction { } } + /// Access the Orchard issue data in this transaction, if any, + /// regardless of version. + #[cfg(feature = "tx-v6")] + fn orchard_issue_data(&self) -> &Option { + match self { + Transaction::V1 { .. } + | Transaction::V2 { .. } + | Transaction::V3 { .. } + | Transaction::V4 { .. } + | Transaction::V5 { .. } => &None, + + Transaction::V6 { + orchard_zsa_issue_data, + .. + } => orchard_zsa_issue_data, + } + } + + /// Access the Orchard issuance actions in this transaction, if there are any, + /// regardless of version. + #[cfg(feature = "tx-v6")] + pub fn orchard_issue_actions(&self) -> impl Iterator { + self.orchard_issue_data() + .iter() + .flat_map(orchard_zsa::IssueData::actions) + } + + /// Access the Orchard asset burns in this transaction, if there are any, + /// regardless of version. + #[cfg(feature = "tx-v6")] + pub fn orchard_burns<'a>(&'a self) -> &[orchard_zsa::BurnItem] { + use crate::orchard::{OrchardVanilla, OrchardZSA}; + orchard_shielded_data_map!( + self, + |data: &'a orchard::ShieldedData| data.burn.as_ref(), + |data: &'a orchard::ShieldedData| data.burn.as_ref() + ) + } + /// Access the [`orchard::Flags`] in this transaction, if there is any, /// regardless of version. pub fn orchard_flags(&self) -> Option { diff --git a/zebra-consensus/src/block.rs b/zebra-consensus/src/block.rs index 611aea2ceba..aa4bf94c72f 100644 --- a/zebra-consensus/src/block.rs +++ b/zebra-consensus/src/block.rs @@ -15,7 +15,7 @@ use std::{ }; use chrono::Utc; -use futures::stream::FuturesUnordered; +use futures::stream::FuturesOrdered; use futures_util::FutureExt; use thiserror::Error; use tower::{Service, ServiceExt}; @@ -226,7 +226,7 @@ where tx::check::coinbase_outputs_are_decryptable(&coinbase_tx, &network, height)?; // Send transactions to the transaction verifier to be checked - let mut async_checks = FuturesUnordered::new(); + let mut async_checks = FuturesOrdered::new(); let known_utxos = Arc::new(transparent::new_ordered_outputs( &block, @@ -243,7 +243,7 @@ where height, time: block.header.time, }); - async_checks.push(rsp); + async_checks.push_back(rsp); } tracing::trace!(len = async_checks.len(), "built async tx checks"); @@ -252,26 +252,32 @@ where // Sum up some block totals from the transaction responses. let mut legacy_sigop_count = 0; let mut block_miner_fees = Ok(Amount::zero()); + let mut issued_assets_changes = Vec::new(); use futures::StreamExt; while let Some(result) = async_checks.next().await { tracing::trace!(?result, remaining = async_checks.len()); - let response = result + let crate::transaction::Response::Block { + tx_id: _, + miner_fee, + legacy_sigop_count: tx_legacy_sigop_count, + issued_assets_change, + } = result .map_err(Into::into) - .map_err(VerifyBlockError::Transaction)?; - - assert!( - matches!(response, tx::Response::Block { .. }), - "unexpected response from transaction verifier: {response:?}" - ); + .map_err(VerifyBlockError::Transaction)? + else { + panic!("unexpected response from transaction verifier"); + }; - legacy_sigop_count += response.legacy_sigop_count(); + legacy_sigop_count += tx_legacy_sigop_count; // Coinbase transactions consume the miner fee, // so they don't add any value to the block's total miner fee. - if let Some(miner_fee) = response.miner_fee() { + if let Some(miner_fee) = miner_fee { block_miner_fees += miner_fee; } + + issued_assets_changes.push(issued_assets_change); } // Check the summed block totals @@ -321,6 +327,7 @@ where new_outputs, transaction_hashes, deferred_balance: Some(expected_deferred_amount), + issued_assets_changes: issued_assets_changes.into(), }; // Return early for proposal requests when getblocktemplate-rpcs feature is enabled diff --git a/zebra-consensus/src/checkpoint.rs b/zebra-consensus/src/checkpoint.rs index 039ea6e33e3..f6520ba5564 100644 --- a/zebra-consensus/src/checkpoint.rs +++ b/zebra-consensus/src/checkpoint.rs @@ -42,7 +42,7 @@ use crate::{ Progress::{self, *}, TargetHeight::{self, *}, }, - error::{BlockError, SubsidyError}, + error::{BlockError, SubsidyError, TransactionError}, funding_stream_values, BoxError, ParameterCheckpoint as _, }; @@ -619,7 +619,8 @@ where }; // don't do precalculation until the block passes basic difficulty checks - let block = CheckpointVerifiedBlock::new(block, Some(hash), expected_deferred_amount); + let block = CheckpointVerifiedBlock::new(block, Some(hash), expected_deferred_amount) + .ok_or_else(|| VerifyBlockError::from(TransactionError::InvalidAssetIssuanceOrBurn))?; crate::block::check::merkle_root_validity( &self.network, diff --git a/zebra-consensus/src/error.rs b/zebra-consensus/src/error.rs index 8fe14c62d52..9aa41103910 100644 --- a/zebra-consensus/src/error.rs +++ b/zebra-consensus/src/error.rs @@ -239,6 +239,9 @@ pub enum TransactionError { #[error("failed to verify ZIP-317 transaction rules, transaction was not inserted to mempool")] #[cfg_attr(any(test, feature = "proptest-impl"), proptest(skip))] Zip317(#[from] zebra_chain::transaction::zip317::Error), + + #[error("failed to validate asset issuance and/or burns")] + InvalidAssetIssuanceOrBurn, } impl From for TransactionError { diff --git a/zebra-consensus/src/router/tests.rs b/zebra-consensus/src/router/tests.rs index 8fe304e3364..05c3a11f7fc 100644 --- a/zebra-consensus/src/router/tests.rs +++ b/zebra-consensus/src/router/tests.rs @@ -2,12 +2,14 @@ use std::{sync::Arc, time::Duration}; +use block::genesis::regtest_genesis_block; use color_eyre::eyre::Report; use once_cell::sync::Lazy; use tower::{layer::Layer, timeout::TimeoutLayer}; use zebra_chain::{ block::Block, + orchard_zsa::tests::vectors::valid_issuance_blocks, serialization::{ZcashDeserialize, ZcashDeserializeInto}, }; use zebra_state as zs; @@ -270,3 +272,30 @@ async fn verify_fail_add_block_checkpoint() -> Result<(), Report> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn verify_issuance_blocks_test() -> Result<(), Report> { + let _init_guard = zebra_test::init(); + + let network = Network::new_regtest(Some(1), None, Some(1)); + let (block_verifier_router, _state_service) = verifiers_from_network(network.clone()).await; + + let block_verifier_router = + TimeoutLayer::new(Duration::from_secs(VERIFY_TIMEOUT_SECONDS)).layer(block_verifier_router); + + let commit_genesis = [( + Request::Commit(regtest_genesis_block()), + Ok(network.genesis_hash()), + )]; + + let commit_issuance_blocks = valid_issuance_blocks() + .into_iter() + .map(|block| (Request::Commit(block.clone()), Ok(block.hash()))); + + Transcript::from(commit_genesis.into_iter().chain(commit_issuance_blocks)) + .check(block_verifier_router.clone()) + .await + .unwrap(); + + Ok(()) +} diff --git a/zebra-consensus/src/transaction.rs b/zebra-consensus/src/transaction.rs index 9083ddaf9ac..240bad080ae 100644 --- a/zebra-consensus/src/transaction.rs +++ b/zebra-consensus/src/transaction.rs @@ -19,6 +19,7 @@ use tracing::Instrument; use zebra_chain::{ amount::{Amount, NonNegative}, block, orchard, + orchard_zsa::IssuedAssetsChange, parameters::{Network, NetworkUpgrade}, primitives::Groth16Proof, sapling, @@ -143,6 +144,10 @@ pub enum Response { /// The number of legacy signature operations in this transaction's /// transparent inputs and outputs. legacy_sigop_count: u64, + + /// The changes to the issued assets map that should be applied for + /// this transaction. + issued_assets_change: IssuedAssetsChange, }, /// A response to a mempool transaction verification request. @@ -473,6 +478,7 @@ where tx_id, miner_fee, legacy_sigop_count, + issued_assets_change: IssuedAssetsChange::from_transaction(&tx).ok_or(TransactionError::InvalidAssetIssuanceOrBurn)?, }, Request::Mempool { transaction, .. } => { let transaction = VerifiedUnminedTx::new( diff --git a/zebra-rpc/src/sync.rs b/zebra-rpc/src/sync.rs index fd323ef64bb..787b2e7c5a8 100644 --- a/zebra-rpc/src/sync.rs +++ b/zebra-rpc/src/sync.rs @@ -13,8 +13,8 @@ use zebra_chain::{ }; use zebra_node_services::rpc_client::RpcRequestClient; use zebra_state::{ - spawn_init_read_only, ChainTipBlock, ChainTipChange, ChainTipSender, CheckpointVerifiedBlock, - LatestChainTip, NonFinalizedState, ReadStateService, SemanticallyVerifiedBlock, ZebraDb, + spawn_init_read_only, ChainTipBlock, ChainTipChange, ChainTipSender, LatestChainTip, + NonFinalizedState, ReadStateService, SemanticallyVerifiedBlock, ZebraDb, MAX_BLOCK_REORG_HEIGHT, }; @@ -262,7 +262,7 @@ impl TrustedChainSync { tokio::task::spawn_blocking(move || { let (height, hash) = db.tip()?; db.block(height.into()) - .map(|block| CheckpointVerifiedBlock::with_hash(block, hash)) + .map(|block| SemanticallyVerifiedBlock::with_hash(block, hash)) .map(ChainTipBlock::from) }) .wait_for_panics() diff --git a/zebra-state/src/arbitrary.rs b/zebra-state/src/arbitrary.rs index 5c0b837566a..183567b5794 100644 --- a/zebra-state/src/arbitrary.rs +++ b/zebra-state/src/arbitrary.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use zebra_chain::{ amount::Amount, block::{self, Block}, + orchard_zsa::IssuedAssetsChange, transaction::Transaction, transparent, value_balance::ValueBalance, @@ -30,6 +31,8 @@ impl Prepare for Arc { let transaction_hashes: Arc<[_]> = block.transactions.iter().map(|tx| tx.hash()).collect(); let new_outputs = transparent::new_ordered_outputs_with_height(&block, height, &transaction_hashes); + let issued_assets_changes = IssuedAssetsChange::from_transactions(&block.transactions) + .expect("prepared blocks should be semantically valid"); SemanticallyVerifiedBlock { block, @@ -38,6 +41,7 @@ impl Prepare for Arc { new_outputs, transaction_hashes, deferred_balance: None, + issued_assets_changes, } } } @@ -96,8 +100,12 @@ impl ContextuallyVerifiedBlock { .map(|outpoint| (outpoint, zero_utxo.clone())) .collect(); - ContextuallyVerifiedBlock::with_block_and_spent_utxos(block, zero_spent_utxos) - .expect("all UTXOs are provided with zero values") + ContextuallyVerifiedBlock::with_block_and_spent_utxos( + block, + zero_spent_utxos, + Default::default(), + ) + .expect("all UTXOs are provided with zero values") } /// Create a [`ContextuallyVerifiedBlock`] from a [`Block`] or [`SemanticallyVerifiedBlock`], @@ -112,6 +120,7 @@ impl ContextuallyVerifiedBlock { new_outputs, transaction_hashes, deferred_balance: _, + issued_assets_changes: _, } = block.into(); Self { @@ -125,6 +134,7 @@ impl ContextuallyVerifiedBlock { spent_outputs: new_outputs, transaction_hashes, chain_value_pool_change: ValueBalance::zero(), + issued_assets: Default::default(), } } } diff --git a/zebra-state/src/error.rs b/zebra-state/src/error.rs index cf495311efb..4a20f5c29d1 100644 --- a/zebra-state/src/error.rs +++ b/zebra-state/src/error.rs @@ -264,6 +264,12 @@ pub enum ValidateContextError { tx_index_in_block: Option, transaction_hash: transaction::Hash, }, + + #[error("burn amounts must be less than issued asset supply")] + InvalidBurn, + + #[error("must not issue finalized assets")] + InvalidIssuance, } /// Trait for creating the corresponding duplicate nullifier error from a nullifier. diff --git a/zebra-state/src/lib.rs b/zebra-state/src/lib.rs index e93a3b8f905..7cfc8304bdd 100644 --- a/zebra-state/src/lib.rs +++ b/zebra-state/src/lib.rs @@ -42,7 +42,8 @@ pub use error::{ ValidateContextError, }; pub use request::{ - CheckpointVerifiedBlock, HashOrHeight, ReadRequest, Request, SemanticallyVerifiedBlock, + CheckpointVerifiedBlock, HashOrHeight, IssuedAssetsOrChange, ReadRequest, Request, + SemanticallyVerifiedBlock, }; pub use response::{KnownBlock, MinedTx, ReadResponse, Response}; pub use service::{ diff --git a/zebra-state/src/request.rs b/zebra-state/src/request.rs index 56be011d48e..0cfa791001c 100644 --- a/zebra-state/src/request.rs +++ b/zebra-state/src/request.rs @@ -11,6 +11,7 @@ use zebra_chain::{ block::{self, Block}, history_tree::HistoryTree, orchard, + orchard_zsa::{IssuedAssets, IssuedAssetsChange}, parallel::tree::NoteCommitmentTrees, sapling, serialization::SerializationError, @@ -163,6 +164,9 @@ pub struct SemanticallyVerifiedBlock { pub transaction_hashes: Arc<[transaction::Hash]>, /// This block's contribution to the deferred pool. pub deferred_balance: Option>, + /// A precomputed list of the [`IssuedAssetsChange`]s for the transactions in this block, + /// in the same order as `block.transactions`. + pub issued_assets_changes: Arc<[IssuedAssetsChange]>, } /// A block ready to be committed directly to the finalized state with @@ -223,6 +227,10 @@ pub struct ContextuallyVerifiedBlock { /// The sum of the chain value pool changes of all transactions in this block. pub(crate) chain_value_pool_change: ValueBalance, + + /// A partial map of `issued_assets` with entries for asset states that were updated in + /// this block. + pub(crate) issued_assets: IssuedAssets, } /// Wraps note commitment trees and the history tree together. @@ -293,12 +301,52 @@ pub struct FinalizedBlock { pub(super) treestate: Treestate, /// This block's contribution to the deferred pool. pub(super) deferred_balance: Option>, + /// Either changes to be applied to the previous `issued_assets` map for the finalized tip, or + /// updates asset states to be inserted into the finalized state, replacing the previous + /// asset states for those asset bases. + pub issued_assets: IssuedAssetsOrChange, +} + +/// Either changes to be applied to the previous `issued_assets` map for the finalized tip, or +/// updates asset states to be inserted into the finalized state, replacing the previous +/// asset states for those asset bases. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IssuedAssetsOrChange { + /// A map of updated issued assets. + Updated(IssuedAssets), + + /// A map of changes to apply to the issued assets map. + Change(IssuedAssetsChange), +} + +impl From> for IssuedAssetsOrChange { + fn from(change: Arc<[IssuedAssetsChange]>) -> Self { + Self::Change( + change + .iter() + .cloned() + .reduce(|a, b| a + b) + .unwrap_or_default(), + ) + } +} + +impl From for IssuedAssetsOrChange { + fn from(updated_issued_assets: IssuedAssets) -> Self { + Self::Updated(updated_issued_assets) + } } impl FinalizedBlock { /// Constructs [`FinalizedBlock`] from [`CheckpointVerifiedBlock`] and its [`Treestate`]. pub fn from_checkpoint_verified(block: CheckpointVerifiedBlock, treestate: Treestate) -> Self { - Self::from_semantically_verified(SemanticallyVerifiedBlock::from(block), treestate) + let issued_assets = block.issued_assets_changes.clone().into(); + + Self::from_semantically_verified( + SemanticallyVerifiedBlock::from(block), + treestate, + issued_assets, + ) } /// Constructs [`FinalizedBlock`] from [`ContextuallyVerifiedBlock`] and its [`Treestate`]. @@ -306,11 +354,20 @@ impl FinalizedBlock { block: ContextuallyVerifiedBlock, treestate: Treestate, ) -> Self { - Self::from_semantically_verified(SemanticallyVerifiedBlock::from(block), treestate) + let issued_assets = block.issued_assets.clone().into(); + Self::from_semantically_verified( + SemanticallyVerifiedBlock::from(block), + treestate, + issued_assets, + ) } /// Constructs [`FinalizedBlock`] from [`SemanticallyVerifiedBlock`] and its [`Treestate`]. - fn from_semantically_verified(block: SemanticallyVerifiedBlock, treestate: Treestate) -> Self { + fn from_semantically_verified( + block: SemanticallyVerifiedBlock, + treestate: Treestate, + issued_assets: IssuedAssetsOrChange, + ) -> Self { Self { block: block.block, hash: block.hash, @@ -319,6 +376,7 @@ impl FinalizedBlock { transaction_hashes: block.transaction_hashes, treestate, deferred_balance: block.deferred_balance, + issued_assets, } } } @@ -384,6 +442,7 @@ impl ContextuallyVerifiedBlock { pub fn with_block_and_spent_utxos( semantically_verified: SemanticallyVerifiedBlock, mut spent_outputs: HashMap, + issued_assets: IssuedAssets, ) -> Result { let SemanticallyVerifiedBlock { block, @@ -392,6 +451,7 @@ impl ContextuallyVerifiedBlock { new_outputs, transaction_hashes, deferred_balance, + issued_assets_changes: _, } = semantically_verified; // This is redundant for the non-finalized state, @@ -411,6 +471,7 @@ impl ContextuallyVerifiedBlock { &utxos_from_ordered_utxos(spent_outputs), deferred_balance, )?, + issued_assets, }) } } @@ -422,11 +483,14 @@ impl CheckpointVerifiedBlock { block: Arc, hash: Option, deferred_balance: Option>, - ) -> Self { + ) -> Option { + let issued_assets_change = IssuedAssetsChange::from_transactions(&block.transactions)?; let mut block = Self::with_hash(block.clone(), hash.unwrap_or(block.hash())); block.deferred_balance = deferred_balance; - block + block.issued_assets_changes = issued_assets_change; + Some(block) } + /// Creates a block that's ready to be committed to the finalized state, /// using a precalculated [`block::Hash`]. /// @@ -453,6 +517,7 @@ impl SemanticallyVerifiedBlock { new_outputs, transaction_hashes, deferred_balance: None, + issued_assets_changes: Arc::new([]), } } @@ -465,7 +530,7 @@ impl SemanticallyVerifiedBlock { impl From> for CheckpointVerifiedBlock { fn from(block: Arc) -> Self { - CheckpointVerifiedBlock(SemanticallyVerifiedBlock::from(block)) + Self(SemanticallyVerifiedBlock::from(block)) } } @@ -485,6 +550,7 @@ impl From> for SemanticallyVerifiedBlock { new_outputs, transaction_hashes, deferred_balance: None, + issued_assets_changes: Arc::new([]), } } } @@ -504,19 +570,7 @@ impl From for SemanticallyVerifiedBlock { .constrain::() .expect("deferred balance in a block must me non-negative"), ), - } - } -} - -impl From for SemanticallyVerifiedBlock { - fn from(finalized: FinalizedBlock) -> Self { - Self { - block: finalized.block, - hash: finalized.hash, - height: finalized.height, - new_outputs: finalized.new_outputs, - transaction_hashes: finalized.transaction_hashes, - deferred_balance: finalized.deferred_balance, + issued_assets_changes: Arc::new([]), } } } diff --git a/zebra-state/src/service/chain_tip.rs b/zebra-state/src/service/chain_tip.rs index 04ea61d6982..8a0ed517766 100644 --- a/zebra-state/src/service/chain_tip.rs +++ b/zebra-state/src/service/chain_tip.rs @@ -116,6 +116,7 @@ impl From for ChainTipBlock { new_outputs: _, transaction_hashes, deferred_balance: _, + issued_assets_changes: _, } = prepared; Self { diff --git a/zebra-state/src/service/check.rs b/zebra-state/src/service/check.rs index ced63bfea16..d2eaeff4e5a 100644 --- a/zebra-state/src/service/check.rs +++ b/zebra-state/src/service/check.rs @@ -28,6 +28,7 @@ use crate::service::non_finalized_state::Chain; pub(crate) mod anchors; pub(crate) mod difficulty; +pub(crate) mod issuance; pub(crate) mod nullifier; pub(crate) mod utxo; diff --git a/zebra-state/src/service/check/issuance.rs b/zebra-state/src/service/check/issuance.rs new file mode 100644 index 00000000000..b130e1c9f90 --- /dev/null +++ b/zebra-state/src/service/check/issuance.rs @@ -0,0 +1,67 @@ +//! Checks for issuance and burn validity. + +use std::{collections::HashMap, sync::Arc}; + +use zebra_chain::orchard_zsa::{AssetBase, AssetState, IssuedAssets}; + +use crate::{SemanticallyVerifiedBlock, ValidateContextError, ZebraDb}; + +use super::Chain; + +// TODO: Factor out chain/disk read to a fn in the `read` module. +fn asset_state( + finalized_state: &ZebraDb, + parent_chain: &Arc, + issued_assets: &HashMap, + asset_base: &AssetBase, +) -> Option { + issued_assets + .get(asset_base) + .copied() + .or_else(|| parent_chain.issued_asset(asset_base)) + .or_else(|| finalized_state.issued_asset(asset_base)) +} + +pub fn valid_burns_and_issuance( + finalized_state: &ZebraDb, + parent_chain: &Arc, + semantically_verified: &SemanticallyVerifiedBlock, +) -> Result { + let mut issued_assets = HashMap::new(); + + for (issued_assets_change, transaction) in semantically_verified + .issued_assets_changes + .iter() + .zip(&semantically_verified.block.transactions) + { + // Check that no burn item attempts to burn more than the issued supply for an asset + for burn in transaction.orchard_burns() { + let asset_base = burn.asset(); + let asset_state = + asset_state(finalized_state, parent_chain, &issued_assets, &asset_base) + .ok_or(ValidateContextError::InvalidBurn)?; + + if asset_state.total_supply < burn.amount() { + return Err(ValidateContextError::InvalidBurn); + } else { + // Any burned asset bases in the transaction will also be present in the issued assets change, + // adding a copy of initial asset state to `issued_assets` avoids duplicate disk reads. + issued_assets.insert(asset_base, asset_state); + } + } + + for (asset_base, change) in issued_assets_change.iter() { + let asset_state = + asset_state(finalized_state, parent_chain, &issued_assets, &asset_base) + .unwrap_or_default(); + + let updated_asset_state = asset_state + .apply_change(change) + .ok_or(ValidateContextError::InvalidIssuance)?; + + issued_assets.insert(asset_base, updated_asset_state); + } + } + + Ok(issued_assets.into()) +} diff --git a/zebra-state/src/service/check/tests.rs b/zebra-state/src/service/check/tests.rs index 9608105766d..8d51105ea26 100644 --- a/zebra-state/src/service/check/tests.rs +++ b/zebra-state/src/service/check/tests.rs @@ -4,3 +4,6 @@ mod anchors; mod nullifier; mod utxo; mod vectors; + +#[cfg(feature = "tx-v6")] +mod issuance; diff --git a/zebra-state/src/service/check/tests/issuance.rs b/zebra-state/src/service/check/tests/issuance.rs new file mode 100644 index 00000000000..71db34328bf --- /dev/null +++ b/zebra-state/src/service/check/tests/issuance.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use zebra_chain::{ + block::{self, genesis::regtest_genesis_block, Block}, + orchard_zsa::{tests::vectors::valid_issuance_blocks, IssuedAssets}, + parameters::Network, +}; + +use crate::{ + check::{self, Chain}, + service::{finalized_state::FinalizedState, write::validate_and_commit_non_finalized}, + CheckpointVerifiedBlock, Config, NonFinalizedState, +}; + +#[test] +fn check_burns_and_issuance() { + let _init_guard = zebra_test::init(); + + let network = Network::new_regtest(Some(1), None, Some(1)); + + let mut finalized_state = FinalizedState::new_with_debug( + &Config::ephemeral(), + &network, + true, + #[cfg(feature = "elasticsearch")] + false, + false, + ); + + let mut non_finalized_state = NonFinalizedState::new(&network); + + let regtest_genesis_block = regtest_genesis_block(); + let regtest_genesis_hash = regtest_genesis_block.hash(); + + finalized_state + .commit_finalized_direct(regtest_genesis_block.into(), None, "test") + .expect("unexpected invalid genesis block test vector"); + + let block = valid_issuance_blocks().first().unwrap().clone(); + let mut header = Arc::::unwrap_or_clone(block.header.clone()); + header.previous_block_hash = regtest_genesis_hash; + header.commitment_bytes = [0; 32].into(); + let block = Arc::new(Block { + header: Arc::new(header), + transactions: block.transactions.clone(), + }); + + let CheckpointVerifiedBlock(block) = CheckpointVerifiedBlock::new(block, None, None) + .expect("semantic validation of issued assets changes should pass"); + + let empty_chain = Chain::new( + &network, + finalized_state + .db + .finalized_tip_height() + .unwrap_or(block::Height::MIN), + finalized_state.db.sprout_tree_for_tip(), + finalized_state.db.sapling_tree_for_tip(), + finalized_state.db.orchard_tree_for_tip(), + finalized_state.db.history_tree(), + finalized_state.db.finalized_value_pool(), + ); + + let block_1_issued_assets = check::issuance::valid_burns_and_issuance( + &finalized_state.db, + &Arc::new(empty_chain), + &block, + ) + .expect("test transactions should be valid"); + + validate_and_commit_non_finalized(&finalized_state.db, &mut non_finalized_state, block) + .expect("validation should succeed"); + + let best_chain = non_finalized_state + .best_chain() + .expect("should have a non-finalized chain"); + + assert_eq!( + IssuedAssets::from(best_chain.issued_assets.clone()), + block_1_issued_assets, + "issued assets for chain should match those of block 1" + ); +} diff --git a/zebra-state/src/service/finalized_state.rs b/zebra-state/src/service/finalized_state.rs index f8c9bade5c1..94328d9e51f 100644 --- a/zebra-state/src/service/finalized_state.rs +++ b/zebra-state/src/service/finalized_state.rs @@ -91,6 +91,7 @@ pub const STATE_COLUMN_FAMILIES_IN_CODE: &[&str] = &[ "orchard_anchors", "orchard_note_commitment_tree", "orchard_note_commitment_subtree", + "orchard_issued_assets", // Chain "history_tree", "tip_chain_value_pool", diff --git a/zebra-state/src/service/finalized_state/disk_format/shielded.rs b/zebra-state/src/service/finalized_state/disk_format/shielded.rs index bcd24d5c604..cb2844d4c08 100644 --- a/zebra-state/src/service/finalized_state/disk_format/shielded.rs +++ b/zebra-state/src/service/finalized_state/disk_format/shielded.rs @@ -9,7 +9,9 @@ use bincode::Options; use zebra_chain::{ block::Height, - orchard, sapling, sprout, + orchard, + orchard_zsa::{AssetBase, AssetState}, + sapling, sprout, subtree::{NoteCommitmentSubtreeData, NoteCommitmentSubtreeIndex}, }; @@ -207,3 +209,46 @@ impl FromDisk for NoteCommitmentSubtreeData { ) } } + +// TODO: Replace `.unwrap()`s with `.expect()`s + +impl IntoDisk for AssetState { + type Bytes = [u8; 9]; + + fn as_bytes(&self) -> Self::Bytes { + [ + vec![self.is_finalized as u8], + self.total_supply.to_be_bytes().to_vec(), + ] + .concat() + .try_into() + .unwrap() + } +} + +impl FromDisk for AssetState { + fn from_bytes(bytes: impl AsRef<[u8]>) -> Self { + let (&is_finalized_byte, bytes) = bytes.as_ref().split_first().unwrap(); + let (&total_supply_bytes, _bytes) = bytes.split_first_chunk().unwrap(); + + Self { + is_finalized: is_finalized_byte != 0, + total_supply: u64::from_be_bytes(total_supply_bytes), + } + } +} + +impl IntoDisk for AssetBase { + type Bytes = [u8; 32]; + + fn as_bytes(&self) -> Self::Bytes { + self.to_bytes() + } +} + +impl FromDisk for AssetBase { + fn from_bytes(bytes: impl AsRef<[u8]>) -> Self { + let (asset_base_bytes, _) = bytes.as_ref().split_first_chunk().unwrap(); + Self::from_bytes(asset_base_bytes).unwrap() + } +} diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/column_family_names.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/column_family_names.snap index d37e037cac7..33f1c76717b 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/column_family_names.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/column_family_names.snap @@ -12,6 +12,7 @@ expression: cf_names "height_by_hash", "history_tree", "orchard_anchors", + "orchard_issued_assets", "orchard_note_commitment_subtree", "orchard_note_commitment_tree", "orchard_nullifiers", diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_0.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_0.snap index 3c333a9fc43..abd4ae001ec 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_0.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_0.snap @@ -5,6 +5,7 @@ expression: empty_column_families [ "balance_by_transparent_addr: no entries", "history_tree: no entries", + "orchard_issued_assets: no entries", "orchard_note_commitment_subtree: no entries", "orchard_nullifiers: no entries", "sapling_note_commitment_subtree: no entries", diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_1.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_1.snap index cb8ac5f6aed..8b114ddce4d 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_1.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_1.snap @@ -4,6 +4,7 @@ expression: empty_column_families --- [ "history_tree: no entries", + "orchard_issued_assets: no entries", "orchard_note_commitment_subtree: no entries", "orchard_nullifiers: no entries", "sapling_note_commitment_subtree: no entries", diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_2.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_2.snap index cb8ac5f6aed..8b114ddce4d 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_2.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_2.snap @@ -4,6 +4,7 @@ expression: empty_column_families --- [ "history_tree: no entries", + "orchard_issued_assets: no entries", "orchard_note_commitment_subtree: no entries", "orchard_nullifiers: no entries", "sapling_note_commitment_subtree: no entries", diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@no_blocks.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@no_blocks.snap index a2abce2083b..2d119139d26 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@no_blocks.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@no_blocks.snap @@ -11,6 +11,7 @@ expression: empty_column_families "height_by_hash: no entries", "history_tree: no entries", "orchard_anchors: no entries", + "orchard_issued_assets: no entries", "orchard_note_commitment_subtree: no entries", "orchard_note_commitment_tree: no entries", "orchard_nullifiers: no entries", diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_0.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_0.snap index 3c333a9fc43..abd4ae001ec 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_0.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_0.snap @@ -5,6 +5,7 @@ expression: empty_column_families [ "balance_by_transparent_addr: no entries", "history_tree: no entries", + "orchard_issued_assets: no entries", "orchard_note_commitment_subtree: no entries", "orchard_nullifiers: no entries", "sapling_note_commitment_subtree: no entries", diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_1.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_1.snap index cb8ac5f6aed..8b114ddce4d 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_1.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_1.snap @@ -4,6 +4,7 @@ expression: empty_column_families --- [ "history_tree: no entries", + "orchard_issued_assets: no entries", "orchard_note_commitment_subtree: no entries", "orchard_nullifiers: no entries", "sapling_note_commitment_subtree: no entries", diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_2.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_2.snap index cb8ac5f6aed..8b114ddce4d 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_2.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_2.snap @@ -4,6 +4,7 @@ expression: empty_column_families --- [ "history_tree: no entries", + "orchard_issued_assets: no entries", "orchard_note_commitment_subtree: no entries", "orchard_nullifiers: no entries", "sapling_note_commitment_subtree: no entries", diff --git a/zebra-state/src/service/finalized_state/zebra_db/block.rs b/zebra-state/src/service/finalized_state/zebra_db/block.rs index 4dc3a801ef3..6f0d2340b91 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/block.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/block.rs @@ -463,7 +463,7 @@ impl DiskWriteBatch { // which is already present from height 1 to the first shielded transaction. // // In Zebra we include the nullifiers and note commitments in the genesis block because it simplifies our code. - self.prepare_shielded_transaction_batch(db, finalized)?; + self.prepare_shielded_transaction_batch(zebra_db, finalized)?; self.prepare_trees_batch(zebra_db, finalized, prev_note_commitment_trees)?; // # Consensus diff --git a/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs b/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs index 194f2202a87..d7df21fda0e 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs @@ -20,6 +20,7 @@ use zebra_chain::{ }, Block, Height, }, + orchard_zsa::IssuedAssetsChange, parameters::Network::{self, *}, serialization::{ZcashDeserializeInto, ZcashSerialize}, transparent::new_ordered_outputs_with_height, @@ -129,6 +130,9 @@ fn test_block_db_round_trip_with( .collect(); let new_outputs = new_ordered_outputs_with_height(&original_block, Height(0), &transaction_hashes); + let issued_assets_changes = + IssuedAssetsChange::from_transactions(&original_block.transactions) + .expect("issued assets should be valid"); CheckpointVerifiedBlock(SemanticallyVerifiedBlock { block: original_block.clone(), @@ -137,6 +141,7 @@ fn test_block_db_round_trip_with( new_outputs, transaction_hashes, deferred_balance: None, + issued_assets_changes, }) }; diff --git a/zebra-state/src/service/finalized_state/zebra_db/shielded.rs b/zebra-state/src/service/finalized_state/zebra_db/shielded.rs index 4bba75b1891..1ca7e9cd3dc 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/shielded.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/shielded.rs @@ -19,7 +19,8 @@ use std::{ use zebra_chain::{ block::Height, - orchard, + orchard::{self}, + orchard_zsa::{AssetBase, AssetState}, parallel::tree::NoteCommitmentTrees, sapling, sprout, subtree::{NoteCommitmentSubtreeData, NoteCommitmentSubtreeIndex}, @@ -33,14 +34,31 @@ use crate::{ disk_format::RawBytes, zebra_db::ZebraDb, }, - BoxError, + BoxError, IssuedAssetsOrChange, TypedColumnFamily, }; // Doc-only items #[allow(unused_imports)] use zebra_chain::subtree::NoteCommitmentSubtree; +/// The name of the chain value pools column family. +/// +/// This constant should be used so the compiler can detect typos. +pub const ISSUED_ASSETS: &str = "orchard_issued_assets"; + +/// The type for reading value pools from the database. +/// +/// This constant should be used so the compiler can detect incorrectly typed accesses to the +/// column family. +pub type IssuedAssetsCf<'cf> = TypedColumnFamily<'cf, AssetBase, AssetState>; + impl ZebraDb { + /// Returns a typed handle to the `history_tree` column family. + pub(crate) fn issued_assets_cf(&self) -> IssuedAssetsCf { + IssuedAssetsCf::new(&self.db, ISSUED_ASSETS) + .expect("column family was created when database was created") + } + // Read shielded methods /// Returns `true` if the finalized state contains `sprout_nullifier`. @@ -410,6 +428,11 @@ impl ZebraDb { Some(subtree_data.with_index(index)) } + /// Get the orchard issued asset state for the finalized tip. + pub fn issued_asset(&self, asset_base: &AssetBase) -> Option { + self.issued_assets_cf().zs_get(asset_base) + } + /// Returns the shielded note commitment trees of the finalized tip /// or the empty trees if the state is empty. /// Additionally, returns the sapling and orchard subtrees for the finalized tip if @@ -437,16 +460,18 @@ impl DiskWriteBatch { /// - Propagates any errors from updating note commitment trees pub fn prepare_shielded_transaction_batch( &mut self, - db: &DiskDb, + zebra_db: &ZebraDb, finalized: &FinalizedBlock, ) -> Result<(), BoxError> { let FinalizedBlock { block, .. } = finalized; // Index each transaction's shielded data for transaction in &block.transactions { - self.prepare_nullifier_batch(db, transaction)?; + self.prepare_nullifier_batch(&zebra_db.db, transaction)?; } + self.prepare_issued_assets_batch(zebra_db, &finalized.issued_assets)?; + Ok(()) } @@ -480,6 +505,33 @@ impl DiskWriteBatch { Ok(()) } + /// Prepare a database batch containing `finalized.block`'s asset issuance + /// and return it (without actually writing anything). + /// + /// # Errors + /// + /// - This method doesn't currently return any errors, but it might in future + #[allow(clippy::unwrap_in_result)] + pub fn prepare_issued_assets_batch( + &mut self, + zebra_db: &ZebraDb, + issued_assets_or_changes: &IssuedAssetsOrChange, + ) -> Result<(), BoxError> { + let mut batch = zebra_db.issued_assets_cf().with_batch_for_writing(self); + + let updated_issued_assets = match issued_assets_or_changes.clone() { + IssuedAssetsOrChange::Updated(issued_assets) => issued_assets, + IssuedAssetsOrChange::Change(issued_assets_change) => issued_assets_change + .apply_with(|asset_base| zebra_db.issued_asset(&asset_base).unwrap_or_default()), + }; + + for (asset_base, updated_issued_asset_state) in updated_issued_assets { + batch = batch.zs_insert(&asset_base, &updated_issued_asset_state); + } + + Ok(()) + } + /// Prepare a database batch containing the note commitment and history tree updates /// from `finalized.block`, and return it (without actually writing anything). /// diff --git a/zebra-state/src/service/non_finalized_state.rs b/zebra-state/src/service/non_finalized_state.rs index 08d64455024..1ca33cb43f4 100644 --- a/zebra-state/src/service/non_finalized_state.rs +++ b/zebra-state/src/service/non_finalized_state.rs @@ -325,6 +325,9 @@ impl NonFinalizedState { finalized_state, )?; + let issued_assets = + check::issuance::valid_burns_and_issuance(finalized_state, &new_chain, &prepared)?; + // Reads from disk check::anchors::block_sapling_orchard_anchors_refer_to_final_treestates( finalized_state, @@ -343,6 +346,8 @@ impl NonFinalizedState { let contextual = ContextuallyVerifiedBlock::with_block_and_spent_utxos( prepared.clone(), spent_utxos.clone(), + // TODO: Refactor this into repeated `With::with()` calls, see http_request_compatibility module. + issued_assets, ) .map_err(|value_balance_error| { ValidateContextError::CalculateBlockChainValueChange { diff --git a/zebra-state/src/service/non_finalized_state/chain.rs b/zebra-state/src/service/non_finalized_state/chain.rs index d0ce3eee904..30f838afbab 100644 --- a/zebra-state/src/service/non_finalized_state/chain.rs +++ b/zebra-state/src/service/non_finalized_state/chain.rs @@ -16,13 +16,16 @@ use zebra_chain::{ block::{self, Height}, history_tree::HistoryTree, orchard, + orchard_zsa::{AssetBase, AssetState, IssuedAssets, IssuedAssetsChange}, parallel::tree::NoteCommitmentTrees, parameters::Network, primitives::Groth16Proof, sapling, sprout, subtree::{NoteCommitmentSubtree, NoteCommitmentSubtreeData, NoteCommitmentSubtreeIndex}, - transaction::Transaction::*, - transaction::{self, Transaction}, + transaction::{ + self, + Transaction::{self, *}, + }, transparent, value_balance::ValueBalance, work::difficulty::PartialCumulativeWork, @@ -174,6 +177,11 @@ pub struct ChainInner { pub(crate) orchard_subtrees: BTreeMap>, + /// A partial map of `issued_assets` with entries for asset states that were updated in + /// this chain. + // TODO: Add reference to ZIP + pub(crate) issued_assets: HashMap, + // Nullifiers // /// The Sprout nullifiers revealed by `blocks`. @@ -237,6 +245,7 @@ impl Chain { orchard_anchors_by_height: Default::default(), orchard_trees_by_height: Default::default(), orchard_subtrees: Default::default(), + issued_assets: Default::default(), sprout_nullifiers: Default::default(), sapling_nullifiers: Default::default(), orchard_nullifiers: Default::default(), @@ -937,6 +946,47 @@ impl Chain { } } + /// Returns the Orchard issued asset state if one is present in + /// the chain for the provided asset base. + pub fn issued_asset(&self, asset_base: &AssetBase) -> Option { + self.issued_assets.get(asset_base).cloned() + } + + /// Remove the History tree index at `height`. + fn revert_issued_assets( + &mut self, + position: RevertPosition, + issued_assets: &IssuedAssets, + transactions: &[Arc], + ) { + if position == RevertPosition::Root { + trace!(?position, "removing unmodified issued assets"); + for (asset_base, &asset_state) in issued_assets.iter() { + if self + .issued_asset(asset_base) + .expect("issued assets for chain should include those in all blocks") + == asset_state + { + self.issued_assets.remove(asset_base); + } + } + } else { + trace!(?position, "reverting changes to issued assets"); + for issued_assets_change in IssuedAssetsChange::from_transactions(transactions) + .expect("blocks in chain state must be valid") + .iter() + .rev() + { + for (asset_base, change) in issued_assets_change.iter() { + self.issued_assets + .entry(asset_base) + .or_default() + .revert_change(change); + } + } + } + } + /// Adds the Orchard `tree` to the tree and anchor indexes at `height`. /// /// `height` can be either: @@ -1439,6 +1489,9 @@ impl Chain { self.add_history_tree(height, history_tree); + self.issued_assets + .extend(contextually_valid.issued_assets.clone()); + Ok(()) } @@ -1667,6 +1720,7 @@ impl UpdateWith for Chain { spent_outputs, transaction_hashes, chain_value_pool_change, + issued_assets, ) = ( contextually_valid.block.as_ref(), contextually_valid.hash, @@ -1675,6 +1729,7 @@ impl UpdateWith for Chain { &contextually_valid.spent_outputs, &contextually_valid.transaction_hashes, &contextually_valid.chain_value_pool_change, + &contextually_valid.issued_assets, ); // remove the blocks hash from `height_by_hash` @@ -1773,6 +1828,9 @@ impl UpdateWith for Chain { // TODO: move this to the history tree UpdateWith.revert...()? self.remove_history_tree(position, height); + // revert the issued assets map, if needed + self.revert_issued_assets(position, issued_assets, &block.transactions); + // revert the chain value pool balances, if needed self.revert_chain_with(chain_value_pool_change, position); } diff --git a/zebra-state/src/service/non_finalized_state/tests/prop.rs b/zebra-state/src/service/non_finalized_state/tests/prop.rs index 2a1adf65c20..16f3ee84f70 100644 --- a/zebra-state/src/service/non_finalized_state/tests/prop.rs +++ b/zebra-state/src/service/non_finalized_state/tests/prop.rs @@ -52,6 +52,7 @@ fn push_genesis_chain() -> Result<()> { ContextuallyVerifiedBlock::with_block_and_spent_utxos( block, only_chain.unspent_utxos(), + Default::default(), ) .map_err(|e| (e, chain_values.clone())) .expect("invalid block value pool change"); @@ -148,6 +149,7 @@ fn forked_equals_pushed_genesis() -> Result<()> { let block = ContextuallyVerifiedBlock::with_block_and_spent_utxos( block, partial_chain.unspent_utxos(), + Default::default() )?; partial_chain = partial_chain .push(block) @@ -167,7 +169,7 @@ fn forked_equals_pushed_genesis() -> Result<()> { for block in chain.iter().cloned() { let block = - ContextuallyVerifiedBlock::with_block_and_spent_utxos(block, full_chain.unspent_utxos())?; + ContextuallyVerifiedBlock::with_block_and_spent_utxos(block, full_chain.unspent_utxos(), Default::default())?; // Check some properties of the genesis block and don't push it to the chain. if block.height == block::Height(0) { @@ -210,7 +212,7 @@ fn forked_equals_pushed_genesis() -> Result<()> { // same original full chain. for block in chain.iter().skip(fork_at_count).cloned() { let block = - ContextuallyVerifiedBlock::with_block_and_spent_utxos(block, forked.unspent_utxos())?; + ContextuallyVerifiedBlock::with_block_and_spent_utxos(block, forked.unspent_utxos(), Default::default())?; forked = forked.push(block).expect("forked chain push is valid"); }