diff --git a/Cargo.lock b/Cargo.lock index 29530543715..7d452b45eee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,7 +441,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "143f5327f23168716be068f8e1014ba2ea16a6c91e8777bc8927da7b51e1df1f" dependencies = [ "bs58", - "hmac", + "hmac 0.13.0-pre.4", "rand_core 0.6.4", "ripemd 0.2.0-pre.4", "secp256k1 0.29.1", @@ -1247,6 +1247,7 @@ dependencies = [ "block-buffer 0.10.4", "const-oid", "crypto-common 0.1.6", + "subtle", ] [[package]] @@ -1305,7 +1306,9 @@ dependencies = [ "der", "digest 0.10.7", "elliptic-curve", + "rfc6979", "signature", + "spki", ] [[package]] @@ -1373,6 +1376,7 @@ dependencies = [ "ff", "generic-array", "group", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -1936,6 +1940,15 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0-pre.4" @@ -2495,6 +2508,7 @@ dependencies = [ "cfg-if 1.0.0", "ecdsa", "elliptic-curve", + "once_cell", "sha2 0.10.8", "signature", ] @@ -3804,6 +3818,16 @@ dependencies = [ "winreg", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rgb" version = "0.8.47" @@ -4059,6 +4083,7 @@ dependencies = [ "base16ct", "der", "generic-array", + "pkcs8", "subtle", "zeroize", ] @@ -6195,6 +6220,7 @@ dependencies = [ "incrementalmerkletree", "itertools 0.13.0", "jubjub", + "k256", "lazy_static", "nonempty 0.7.0", "num-integer", diff --git a/zebra-chain/Cargo.toml b/zebra-chain/Cargo.toml index f202955e0b4..910dab92fa7 100644 --- a/zebra-chain/Cargo.toml +++ b/zebra-chain/Cargo.toml @@ -66,6 +66,9 @@ tx_v6 = [ "nonempty" ] +# Support for ZSA asset swaps +zsa-swap = [] + [dependencies] # Cryptography @@ -158,6 +161,8 @@ rand_chacha = { version = "0.3.1", optional = true } zebra-test = { path = "../zebra-test/", version = "1.0.0-beta.41", optional = true } +k256 = "0.13.3" + [dev-dependencies] # Benchmarks criterion = { version = "0.5.1", features = ["html_reports"] } diff --git a/zebra-chain/src/orchard.rs b/zebra-chain/src/orchard.rs index 4d7bbe0df5a..2d6696cd94f 100644 --- a/zebra-chain/src/orchard.rs +++ b/zebra-chain/src/orchard.rs @@ -23,7 +23,7 @@ pub use address::Address; pub use commitment::{CommitmentRandomness, NoteCommitment, ValueCommitment}; pub use keys::Diversifier; pub use note::{EncryptedNote, Note, Nullifier, WrappedNoteKey}; -pub use shielded_data::{AuthorizedAction, Flags, ShieldedData}; +pub use shielded_data::{ActionGroup, AuthorizedAction, Flags, ShieldedData}; pub use shielded_data_flavor::{OrchardVanilla, ShieldedDataFlavor}; #[cfg(feature = "tx_v6")] diff --git a/zebra-chain/src/orchard/shielded_data.rs b/zebra-chain/src/orchard/shielded_data.rs index 2acf2c20519..458094cdad1 100644 --- a/zebra-chain/src/orchard/shielded_data.rs +++ b/zebra-chain/src/orchard/shielded_data.rs @@ -28,7 +28,8 @@ use orchard::{note::AssetBase, value::ValueSum}; use super::{OrchardVanilla, ShieldedDataFlavor}; -/// A bundle of [`Action`] descriptions and signature data. +// FIXME: wrap all ActionGroup usages with tx_v6 feature flag? +/// Action Group description. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[cfg_attr( not(feature = "tx_v6"), @@ -41,40 +42,77 @@ use super::{OrchardVanilla, ShieldedDataFlavor}; deserialize = "Flavor::BurnType: serde::Deserialize<'de>" )) )] -pub struct ShieldedData { +pub struct ActionGroup { /// The orchard flags for this transaction. /// Denoted as `flagsOrchard` in the spec. pub flags: Flags, - /// The net value of Orchard spends minus outputs. - /// Denoted as `valueBalanceOrchard` in the spec. - pub value_balance: Amount, /// The shared anchor for all `Spend`s in this transaction. /// Denoted as `anchorOrchard` in the spec. pub shared_anchor: tree::Root, + + /// Block height after which this Bundle's Actions are invalid by consensus. + /// Denoted as `nAGExpiryHeight` in the spec. + #[cfg(feature = "tx_v6")] + pub expiry_height: u32, + + /// Assets intended for burning + /// Denoted as `vAssetBurn` in the spec (ZIP 230). + #[cfg(feature = "tx_v6")] + pub burn: Flavor::BurnType, + /// The aggregated zk-SNARK proof for all the actions in this transaction. /// Denoted as `proofsOrchard` in the spec. pub proof: Halo2Proof, /// The Orchard Actions, in the order they appear in the transaction. /// Denoted as `vActionsOrchard` and `vSpendAuthSigsOrchard` in the spec. pub actions: AtLeastOne>, +} + +impl ActionGroup { + /// Iterate over the [`Action`]s for the [`AuthorizedAction`]s in this + /// action group, in the order they appear in it. + pub fn actions(&self) -> impl Iterator> { + self.actions.actions() + } +} + +/// A bundle of [`Action`] descriptions and signature data. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr( + not(feature = "tx_v6"), + serde(bound(serialize = "Flavor::EncryptedNote: serde::Serialize")) +)] +#[cfg_attr( + feature = "tx_v6", + serde(bound( + serialize = "Flavor::EncryptedNote: serde::Serialize, Flavor::BurnType: serde::Serialize", + deserialize = "Flavor::BurnType: serde::Deserialize<'de>" + )) +)] +pub struct ShieldedData { + /// Action Group descriptions. + /// Denoted as `vActionGroupsOrchard` in the spec (ZIP 230). + pub action_groups: AtLeastOne>, + /// Denoted as `valueBalanceOrchard` in the spec. + pub value_balance: Amount, /// A signature on the transaction `sighash`. /// Denoted as `bindingSigOrchard` in the spec. pub binding_sig: Signature, - - #[cfg(feature = "tx_v6")] - /// Assets intended for burning - /// Denoted as `vAssetBurn` in the spec (ZIP 230). - pub burn: Flavor::BurnType, } -impl fmt::Display for ShieldedData { +impl fmt::Display for ActionGroup { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut fmter = f.debug_struct("orchard::ShieldedData"); + let mut fmter = f.debug_struct("orchard::ActionGroup"); fmter.field("actions", &self.actions.len()); - fmter.field("value_balance", &self.value_balance); fmter.field("flags", &self.flags); + #[cfg(feature = "tx_v6")] + fmter.field("expiry_height", &self.expiry_height); + + #[cfg(feature = "tx_v6")] + fmter.field("burn", &self.burn.as_ref().len()); + fmter.field("proof_len", &self.proof.zcash_serialized_size()); fmter.field("shared_anchor", &self.shared_anchor); @@ -83,11 +121,25 @@ impl fmt::Display for ShieldedData { } } +impl fmt::Display for ShieldedData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut fmter = f.debug_struct("orchard::ShieldedData"); + + fmter.field("action_groups", &self.action_groups); + fmter.field("value_balance", &self.value_balance); + + // FIXME: format binding_sig as well? + + fmter.finish() + } +} + impl ShieldedData { /// Iterate over the [`Action`]s for the [`AuthorizedAction`]s in this /// transaction, in the order they appear in it. pub fn actions(&self) -> impl Iterator> { - self.actions.actions() + self.authorized_actions() + .map(|authorized_action| &authorized_action.action) } /// Collect the [`Nullifier`]s for this transaction. @@ -139,7 +191,11 @@ impl ShieldedData { (ValueSum::default() + i64::from(self.value_balance)).unwrap(), AssetBase::native(), ); - let burn_value_commitment = compute_burn_value_commitment(self.burn.as_ref()); + let burn_value_commitment = self + .action_groups + .iter() + .map(|action_group| compute_burn_value_commitment(action_group.burn.as_ref())) + .sum::(); cv - cv_balance - burn_value_commitment }; @@ -160,6 +216,29 @@ impl ShieldedData { pub fn note_commitments(&self) -> impl Iterator { self.actions().map(|action| &action.cm_x) } + + /// Makes a union of the flags for this transaction. + pub fn flags_union(&self) -> Flags { + self.action_groups + .iter() + .map(|action_group| &action_group.flags) + .fold(Flags::empty(), |result, flags| result.union(*flags)) + } + + /// Collect the shared anchors for this transaction. + pub fn shared_anchors(&self) -> impl Iterator + '_ { + self.action_groups + .iter() + .map(|action_group| action_group.shared_anchor) + } + + /// Iterate over the [`AuthorizedAction`]s in this + /// transaction, in the order they appear in it. + pub fn authorized_actions(&self) -> impl Iterator> { + self.action_groups + .iter() + .flat_map(|action_group| action_group.actions.iter()) + } } impl AtLeastOne> { diff --git a/zebra-chain/src/orchard/shielded_data_flavor.rs b/zebra-chain/src/orchard/shielded_data_flavor.rs index 11ef89df399..eec474574d0 100644 --- a/zebra-chain/src/orchard/shielded_data_flavor.rs +++ b/zebra-chain/src/orchard/shielded_data_flavor.rs @@ -53,6 +53,8 @@ pub trait ShieldedDataFlavor: OrchardFlavor { #[cfg(feature = "tx_v6")] type BurnType: Clone + Debug + + PartialEq + + Eq + ZcashDeserialize + ZcashSerialize + AsRef<[BurnItem]> diff --git a/zebra-chain/src/orchard_zsa.rs b/zebra-chain/src/orchard_zsa.rs index a90c115a9b3..1f5c4bc7cc2 100644 --- a/zebra-chain/src/orchard_zsa.rs +++ b/zebra-chain/src/orchard_zsa.rs @@ -3,8 +3,14 @@ #[cfg(any(test, feature = "proptest-impl"))] mod arbitrary; +pub mod asset_state; mod burn; mod issuance; -pub(crate) use burn::{compute_burn_value_commitment, Burn, BurnItem, NoBurn}; +pub(crate) use burn::{compute_burn_value_commitment, Burn, NoBurn}; pub(crate) use issuance::IssueData; + +pub use burn::BurnItem; + +// FIXME: should asset_state mod be pub and these structs be pub as well? +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..0c9d6ae8b96 --- /dev/null +++ b/zebra-chain/src/orchard_zsa/asset_state.rs @@ -0,0 +1,393 @@ +//! Defines and implements the issued asset state types + +use std::{collections::HashMap, sync::Arc}; + +use orchard::issuance::{compute_asset_desc_hash, IssueAction}; +use orchard::issuance_auth::{IssueAuthKey, IssueValidatingKey, ZSASchnorr}; +pub use orchard::note::AssetBase; + +use crate::{serialization::ZcashSerialize, transaction::Transaction}; + +use super::{BurnItem, IssueData}; + +/// The circulating supply and whether that supply has been finalized. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +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 has been issued in this change. + 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) + } +} + +// FIXME: can we reuse some functions from orchard crate?s +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), + // FIXME: (-signed) - is this a correct fix? + ..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( + tx.orchard_issue_data() + .iter() + .flat_map(Self::from_issue_data), + ) + } + + /// Accepts an [`IssueData`] 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_data(issue_data: &IssueData) -> impl Iterator + '_ { + let ik = issue_data.inner().ik(); + issue_data.actions().flat_map(|action| { + let issue_asset = AssetBase::derive(ik, action.asset_desc_hash()); + Self::from_issue_action(issue_asset, 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( + issue_asset: AssetBase, + action: &IssueAction, + ) -> impl Iterator + '_ { + (action.is_finalized() && action.notes().is_empty()) + .then(|| Self::new(issue_asset, SupplyChange::Issuance(0), true)) + .into_iter() + .chain(action.notes().iter().map(|note| { + Self::new( + note.asset(), + SupplyChange::Issuance(note.value().inner()), + action.is_finalized(), + ) + })) + } + + /// 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<'a>( + burns: impl Iterator + 'a, + ) -> impl Iterator + 'a { + burns.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.raw_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 + } + } +} + +impl From> for IssuedAssetsChange { + fn from(change: Arc<[IssuedAssetsChange]>) -> Self { + change + .iter() + .cloned() + .reduce(|a, b| a + b) + .unwrap_or_default() + } +} + +/// Used in snapshot test for `getassetstate` RPC method. +// TODO: Replace with `AssetBase::random()` or a known value. +pub trait RandomAssetBase { + /// Generates a ZSA random asset. + /// + /// This is only used in tests. + fn random_serialized() -> String; +} + +impl RandomAssetBase for AssetBase { + fn random_serialized() -> String { + let isk = IssueAuthKey::::random(&mut rand_core::OsRng); + let ik = IssueValidatingKey::::from(&isk); + let desc = b"zsa_asset"; + let hash = compute_asset_desc_hash(&(desc[0], desc[1..].to_vec()).into()); + AssetBase::derive(&ik, &hash) + .zcash_serialize_to_vec() + .map(hex::encode) + .expect("random asset base should serialize") + } +} diff --git a/zebra-chain/src/orchard_zsa/issuance.rs b/zebra-chain/src/orchard_zsa/issuance.rs index 856b64cccde..1c3f8b8614f 100644 --- a/zebra-chain/src/orchard_zsa/issuance.rs +++ b/zebra-chain/src/orchard_zsa/issuance.rs @@ -8,7 +8,7 @@ use group::ff::PrimeField; use halo2::pasta::pallas; use orchard::{ - issuance::{IssueBundle, Signed}, + issuance::{IssueAction, IssueBundle, Signed}, note::ExtractedNoteCommitment, }; @@ -47,6 +47,11 @@ impl IssueData { }) }) } + + /// Returns issuance actions + pub fn actions(&self) -> impl Iterator { + self.0.actions().iter() + } } impl ZcashSerialize for Option { diff --git a/zebra-chain/src/primitives/zcash_note_encryption.rs b/zebra-chain/src/primitives/zcash_note_encryption.rs index c030a13aa26..a43e2f411ab 100644 --- a/zebra-chain/src/primitives/zcash_note_encryption.rs +++ b/zebra-chain/src/primitives/zcash_note_encryption.rs @@ -70,8 +70,8 @@ pub fn decrypts_successfully(transaction: &Transaction, network: &Network, heigh } /// Checks if all actions in an Orchard bundle decrypt successfully. -fn orchard_bundle_decrypts_successfully( - bundle: &Bundle, +fn orchard_bundle_decrypts_successfully( + bundle: &Bundle, ) -> bool { bundle.actions().iter().all(|act| { zcash_note_encryption::try_output_recovery_with_ovk( diff --git a/zebra-chain/src/transaction.rs b/zebra-chain/src/transaction.rs index 4f8762baeb3..b1efdef3a51 100644 --- a/zebra-chain/src/transaction.rs +++ b/zebra-chain/src/transaction.rs @@ -204,32 +204,6 @@ impl fmt::Display for Transaction { } } -// Macro to get a specific field from an Orchard shielded data struct. -// Returns `None` for transaction versions that don't support Orchard (V1-V4). -// This avoids repeating the same match block pattern across multiple accessor methods. -macro_rules! orchard_shielded_data_field { - ($self:expr, $field:ident) => { - match $self { - // No Orchard shielded data - Transaction::V1 { .. } - | Transaction::V2 { .. } - | Transaction::V3 { .. } - | Transaction::V4 { .. } => None, - - Transaction::V5 { - orchard_shielded_data, - .. - } => orchard_shielded_data.as_ref().map(|data| data.$field), - - #[cfg(feature = "tx_v6")] - Transaction::V6 { - orchard_shielded_data, - .. - } => orchard_shielded_data.as_ref().map(|data| data.$field), - } - }; -} - impl Transaction { // identifiers and hashes @@ -319,11 +293,12 @@ impl Transaction { /// /// See [`Self::has_transparent_or_shielded_inputs`] for details. pub fn has_shielded_inputs(&self) -> bool { + // FIXME: is it correct to use orchard_flags_union here? self.joinsplit_count() > 0 || self.sapling_spends_per_anchor().count() > 0 || (self.orchard_action_count() > 0 && self - .orchard_flags() + .orchard_flags_union() .unwrap_or_else(orchard::Flags::empty) .contains(orchard::Flags::ENABLE_SPENDS)) } @@ -332,11 +307,12 @@ impl Transaction { /// /// See [`Self::has_transparent_or_shielded_outputs`] for details. pub fn has_shielded_outputs(&self) -> bool { + // FIXME: is it correct to use orchard_flags_union here? self.joinsplit_count() > 0 || self.sapling_outputs().count() > 0 || (self.orchard_action_count() > 0 && self - .orchard_flags() + .orchard_flags_union() .unwrap_or_else(orchard::Flags::empty) .contains(orchard::Flags::ENABLE_OUTPUTS)) } @@ -351,7 +327,7 @@ impl Transaction { if self.version() < 5 || self.orchard_action_count() == 0 { return true; } - self.orchard_flags() + self.orchard_flags_union() .unwrap_or_else(orchard::Flags::empty) .intersects(orchard::Flags::ENABLE_SPENDS | orchard::Flags::ENABLE_OUTPUTS) } @@ -1107,22 +1083,130 @@ impl Transaction { } } + /// Access the Orchard issue data in this transaction, if any, + /// regardless of version. + #[cfg(feature = "tx_v6")] + pub 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 asset burns in this transaction, if there are any, + /// regardless of version. + #[cfg(feature = "tx_v6")] + pub fn orchard_burns(&self) -> Box + '_> { + match self { + Transaction::V1 { .. } + | Transaction::V2 { .. } + | Transaction::V3 { .. } + | Transaction::V4 { .. } + | Transaction::V5 { .. } => Box::new(std::iter::empty()), + + Transaction::V6 { + orchard_shielded_data, + .. + } => Box::new(orchard_shielded_data.iter().flat_map(|data| { + data.action_groups + .iter() + .flat_map(|action_group| action_group.burn.as_ref().iter()) + })), + } + } + /// Access the [`orchard::Flags`] in this transaction, if there is any, /// regardless of version. - pub fn orchard_flags(&self) -> Option { - orchard_shielded_data_field!(self, flags) + pub fn orchard_flags_union(&self) -> Option { + match self { + Transaction::V1 { .. } + | Transaction::V2 { .. } + | Transaction::V3 { .. } + | Transaction::V4 { .. } => None, + + Transaction::V5 { + orchard_shielded_data, + .. + } => orchard_shielded_data + .as_ref() + .map(|data| data.flags_union()), + + #[cfg(feature = "tx_v6")] + Transaction::V6 { + orchard_shielded_data, + .. + } => orchard_shielded_data + .as_ref() + .map(|data| data.flags_union()), + } } /// Access the [`orchard::tree::Root`] in this transaction, if there is any, /// regardless of version. - pub fn orchard_shared_anchor(&self) -> Option { - orchard_shielded_data_field!(self, shared_anchor) + pub fn orchard_shared_anchors(&self) -> Box + '_> { + match self { + Transaction::V1 { .. } + | Transaction::V2 { .. } + | Transaction::V3 { .. } + | Transaction::V4 { .. } => Box::new(std::iter::empty()), + + Transaction::V5 { + orchard_shielded_data, + .. + } => Box::new( + orchard_shielded_data + .iter() + .flat_map(orchard::ShieldedData::shared_anchors), + ), + + #[cfg(feature = "tx_v6")] + Transaction::V6 { + orchard_shielded_data, + .. + } => Box::new( + orchard_shielded_data + .iter() + .flat_map(orchard::ShieldedData::shared_anchors), + ), + } } /// Return if the transaction has any Orchard shielded data, /// regardless of version. pub fn has_orchard_shielded_data(&self) -> bool { - self.orchard_flags().is_some() + // FIXME: avoid code duplication with orchard_value_balance + let value_balance = match self { + // No Orchard shielded data + Transaction::V1 { .. } + | Transaction::V2 { .. } + | Transaction::V3 { .. } + | Transaction::V4 { .. } => None, + + Transaction::V5 { + orchard_shielded_data, + .. + } => orchard_shielded_data + .as_ref() + .map(|data| data.value_balance()), + + #[cfg(feature = "tx_v6")] + Transaction::V6 { + orchard_shielded_data, + .. + } => orchard_shielded_data + .as_ref() + .map(|data| data.value_balance()), + }; + + value_balance.is_some() } // value balances @@ -1377,10 +1461,29 @@ impl Transaction { /// /// pub fn orchard_value_balance(&self) -> ValueBalance { - let orchard_value_balance = - orchard_shielded_data_field!(self, value_balance).unwrap_or_else(Amount::zero); + let value_balance = match self { + Transaction::V1 { .. } + | Transaction::V2 { .. } + | Transaction::V3 { .. } + | Transaction::V4 { .. } => None, + + Transaction::V5 { + orchard_shielded_data, + .. + } => orchard_shielded_data + .as_ref() + .map(|data| data.value_balance()), + + #[cfg(feature = "tx_v6")] + Transaction::V6 { + orchard_shielded_data, + .. + } => orchard_shielded_data + .as_ref() + .map(|data| data.value_balance()), + }; - ValueBalance::from_orchard_amount(orchard_value_balance) + ValueBalance::from_orchard_amount(value_balance.unwrap_or_else(Amount::zero)) } /// Returns the value balances for this transaction using the provided transparent outputs. diff --git a/zebra-chain/src/transaction/arbitrary.rs b/zebra-chain/src/transaction/arbitrary.rs index 96166e4c957..afd4f4ebcca 100644 --- a/zebra-chain/src/transaction/arbitrary.rs +++ b/zebra-chain/src/transaction/arbitrary.rs @@ -14,7 +14,7 @@ use crate::{ parameters::{Network, NetworkUpgrade}, primitives::{Bctv14Proof, Groth16Proof, Halo2Proof, ZkSnarkProof}, sapling::{self, AnchorVariant, PerSpendAnchor, SharedAnchor}, - serialization::ZcashDeserializeInto, + serialization::{AtLeastOne, ZcashDeserializeInto}, sprout, transparent, value_balance::{ValueBalance, ValueBalanceError}, LedgerState, @@ -807,16 +807,21 @@ impl Arbitrary for orchard::Shiel props; Self { - flags, + action_groups: AtLeastOne::from_one(orchard::ActionGroup { + flags, + shared_anchor, + // FIXME: use a proper arbitrary value here? + #[cfg(feature = "tx_v6")] + expiry_height: 0, + #[cfg(feature = "tx_v6")] + burn, + proof, + actions: actions + .try_into() + .expect("arbitrary vector size range produces at least one action"), + }), value_balance, - shared_anchor, - proof, - actions: actions - .try_into() - .expect("arbitrary vector size range produces at least one action"), binding_sig: binding_sig.0, - #[cfg(feature = "tx_v6")] - burn, } }) .boxed() @@ -1169,14 +1174,19 @@ pub fn insert_fake_orchard_shielded_data( // Place the dummy action inside the Orchard shielded data let dummy_shielded_data = orchard::ShieldedData:: { - flags: orchard::Flags::empty(), + action_groups: AtLeastOne::from_one(orchard::ActionGroup { + flags: orchard::Flags::empty(), + shared_anchor: orchard::tree::Root::default(), + // FIXME: use a proper arbitrary value here? + #[cfg(feature = "tx_v6")] + expiry_height: 0, + #[cfg(feature = "tx_v6")] + burn: Default::default(), + proof: Halo2Proof(vec![]), + actions: at_least_one![dummy_authorized_action], + }), value_balance: Amount::try_from(0).expect("invalid transaction amount"), - shared_anchor: orchard::tree::Root::default(), - proof: Halo2Proof(vec![]), - actions: at_least_one![dummy_authorized_action], binding_sig: Signature::from([0u8; 64]), - #[cfg(feature = "tx_v6")] - burn: Default::default(), }; // Replace the shielded data in the transaction diff --git a/zebra-chain/src/transaction/serialize.rs b/zebra-chain/src/transaction/serialize.rs index 2804b099b32..ac3e91e5743 100644 --- a/zebra-chain/src/transaction/serialize.rs +++ b/zebra-chain/src/transaction/serialize.rs @@ -10,7 +10,7 @@ use reddsa::{orchard::Binding, orchard::SpendAuth, Signature}; use crate::{ block::MAX_BLOCK_BYTES, - orchard::{OrchardVanilla, ShieldedDataFlavor}, + orchard::{ActionGroup, OrchardVanilla, ShieldedDataFlavor}, parameters::{OVERWINTER_VERSION_GROUP_ID, SAPLING_VERSION_GROUP_ID, TX_V5_VERSION_GROUP_ID}, primitives::{Halo2Proof, ZkSnarkProof}, serialization::{ @@ -358,11 +358,18 @@ where impl ZcashSerialize for orchard::ShieldedData { fn zcash_serialize(&self, mut writer: W) -> Result<(), io::Error> { + assert!( + self.action_groups.len() == 1, + "V6 transaction must contain exactly one action group" + ); + + let action_group = self.action_groups.first(); + // Split the AuthorizedAction let (actions, sigs): ( Vec>, Vec>, - ) = self + ) = action_group .actions .iter() .cloned() @@ -373,16 +380,16 @@ impl ZcashSerialize for orchard::ShieldedData { actions.zcash_serialize(&mut writer)?; // Denoted as `flagsOrchard` in the spec. - self.flags.zcash_serialize(&mut writer)?; + action_group.flags.zcash_serialize(&mut writer)?; // Denoted as `valueBalanceOrchard` in the spec. self.value_balance.zcash_serialize(&mut writer)?; // Denoted as `anchorOrchard` in the spec. - self.shared_anchor.zcash_serialize(&mut writer)?; + action_group.shared_anchor.zcash_serialize(&mut writer)?; // Denoted as `sizeProofsOrchard` and `proofsOrchard` in the spec. - self.proof.zcash_serialize(&mut writer)?; + action_group.proof.zcash_serialize(&mut writer)?; // Denoted as `vSpendAuthSigsOrchard` in the spec. zcash_serialize_external_count(&sigs, &mut writer)?; @@ -398,44 +405,58 @@ impl ZcashSerialize for orchard::ShieldedData { #[allow(clippy::unwrap_in_result)] impl ZcashSerialize for orchard::ShieldedData { fn zcash_serialize(&self, mut writer: W) -> Result<(), io::Error> { + #[cfg(not(feature = "zsa-swap"))] + assert!( + self.action_groups.len() == 1, + "TxV6 currently supports only one action group" + ); + // Denoted as `nActionGroupsOrchard` in the spec (ZIP 230). // TxV6 currently supports only one action group. - CompactSizeMessage::try_from(1) - .unwrap_or_else(|_| unreachable!()) + CompactSizeMessage::try_from(self.action_groups.len()) + .expect("nActionGroupsOrchard should convert to CompactSizeMessage") .zcash_serialize(&mut writer)?; - // Split the AuthorizedAction - let (actions, sigs): ( - Vec>, - Vec>>, - ) = self - .actions - .iter() - .cloned() - .map(orchard::AuthorizedAction::into_parts) - .map(|(action, sig)| (action, VersionedSigV0::new(sig))) - .unzip(); - - // Denoted as `nActionsOrchard` and `vActionsOrchard` in the spec. - actions.zcash_serialize(&mut writer)?; - - // Denoted as `flagsOrchard` in the spec. - self.flags.zcash_serialize(&mut writer)?; - - // Denoted as `anchorOrchard` in the spec. - self.shared_anchor.zcash_serialize(&mut writer)?; - - // Denoted as `nAGExpiryHeight` in the spec (ZIP 230) (must be zero for V6/NU7). - writer.write_u32::(0)?; - - // Denoted as `vAssetBurn` in the spec (ZIP 230). - self.burn.zcash_serialize(&mut writer)?; - - // Denoted as `sizeProofsOrchard` and `proofsOrchard` in the spec. - self.proof.zcash_serialize(&mut writer)?; - - // Denoted as `vSpendAuthSigsOrchard` in the spec. - zcash_serialize_external_count(&sigs, &mut writer)?; + // FIXME: consider using use zcash_serialize_external_count for or Vec::zcash_serialize + for action_group in self.action_groups.iter() { + // Split the AuthorizedAction + let (actions, sigs): ( + Vec>, + Vec>>, + ) = action_group + .actions + .iter() + .cloned() + .map(orchard::AuthorizedAction::into_parts) + .map(|(action, sig)| (action, VersionedSigV0::new(sig))) + .unzip(); + + // Denoted as `nActionsOrchard` and `vActionsOrchard` in the spec. + actions.zcash_serialize(&mut writer)?; + + // Denoted as `flagsOrchard` in the spec. + action_group.flags.zcash_serialize(&mut writer)?; + + // Denoted as `anchorOrchard` in the spec. + action_group.shared_anchor.zcash_serialize(&mut writer)?; + + // Denoted as `nAGExpiryHeight` in the spec (ZIP 230). + #[cfg(not(feature = "zsa-swap"))] + assert!( + action_group.expiry_height == 0, + "nAGExpiryHeight must be zero for TxV6" + ); + writer.write_u32::(action_group.expiry_height)?; + + // Denoted as `vAssetBurn` in the spec (ZIP 230). + action_group.burn.zcash_serialize(&mut writer)?; + + // Denoted as `sizeProofsOrchard` and `proofsOrchard` in the spec. + action_group.proof.zcash_serialize(&mut writer)?; + + // Denoted as `vSpendAuthSigsOrchard` in the spec. + zcash_serialize_external_count(&sigs, &mut writer)?; + } // Denoted as `valueBalanceOrchard` in the spec. self.value_balance.zcash_serialize(&mut writer)?; @@ -511,13 +532,17 @@ impl ZcashDeserialize for Option> { authorized_actions.try_into()?; Ok(Some(orchard::ShieldedData:: { - flags, + action_groups: AtLeastOne::from_one(ActionGroup { + flags, + shared_anchor, + #[cfg(feature = "tx_v6")] + expiry_height: 0, + #[cfg(feature = "tx_v6")] + burn: NoBurn, + proof, + actions, + }), value_balance, - #[cfg(feature = "tx_v6")] - burn: NoBurn, - shared_anchor, - proof, - actions, binding_sig, })) } @@ -530,49 +555,80 @@ impl ZcashDeserialize for Option> { impl ZcashDeserialize for Option> { fn zcash_deserialize(mut reader: R) -> Result { // Denoted as `nActionGroupsOrchard` in the spec (ZIP 230). - // TxV6 currently supports only one action group. let n_action_groups: usize = (&mut reader) .zcash_deserialize_into::()? .into(); + if n_action_groups == 0 { return Ok(None); - } else if n_action_groups != 1 { + } + + #[cfg(not(feature = "zsa-swap"))] + if n_action_groups != 1 { return Err(SerializationError::Parse( - "V6 transaction must contain exactly one action group", + "TxV6 currently supports only one action group.", )); } - // Denoted as `nActionsOrchard` and `vActionsOrchard` in the spec. - let actions: Vec> = (&mut reader).zcash_deserialize_into()?; + let mut action_groups = Vec::with_capacity(n_action_groups); + + // FIXME: use zcash_deserialize_external_count for or Vec::zcash_deserialize for allocation safety + for _ in 0..n_action_groups { + // Denoted as `nActionsOrchard` and `vActionsOrchard` in the spec. + let actions: Vec> = + (&mut reader).zcash_deserialize_into()?; + + // Denoted as `flagsOrchard` in the spec. + // Consensus: type of each flag is 𝔹, i.e. a bit. This is enforced implicitly + // in [`Flags::zcash_deserialized`]. + let flags: orchard::Flags = (&mut reader).zcash_deserialize_into()?; + + // Denoted as `anchorOrchard` in the spec. + // Consensus: type is `{0 .. 𝑞_ℙ − 1}`. See [`orchard::tree::Root::zcash_deserialize`]. + let shared_anchor: orchard::tree::Root = (&mut reader).zcash_deserialize_into()?; + + // Denoted as `nAGExpiryHeight` in the spec (ZIP 230). + let expiry_height = reader.read_u32::()?; + #[cfg(not(feature = "zsa-swap"))] + if expiry_height != 0 { + return Err(SerializationError::Parse( + "nAGExpiryHeight must be zero for TxV6", + )); + } - // Denoted as `flagsOrchard` in the spec. - // Consensus: type of each flag is 𝔹, i.e. a bit. This is enforced implicitly - // in [`Flags::zcash_deserialized`]. - let flags: orchard::Flags = (&mut reader).zcash_deserialize_into()?; + // Denoted as `vAssetBurn` in the spec (ZIP 230). + let burn = (&mut reader).zcash_deserialize_into()?; - // Denoted as `anchorOrchard` in the spec. - // Consensus: type is `{0 .. 𝑞_ℙ − 1}`. See [`orchard::tree::Root::zcash_deserialize`]. - let shared_anchor: orchard::tree::Root = (&mut reader).zcash_deserialize_into()?; + // Denoted as `sizeProofsOrchard` and `proofsOrchard` in the spec. + // Consensus: type is `ZKAction.Proof`, i.e. a byte sequence. + // https://zips.z.cash/protocol/protocol.pdf#halo2encoding + let proof: Halo2Proof = (&mut reader).zcash_deserialize_into()?; - // Denoted as `nAGExpiryHeight` in the spec (ZIP 230) (must be zero for V6/NU7). - let n_ag_expiry_height = reader.read_u32::()?; - if n_ag_expiry_height != 0 { - return Err(SerializationError::Parse( - "nAGExpiryHeight must be zero for NU7", - )); - } + // Denoted as `vSpendAuthSigsOrchard` in the spec. + let spend_sigs: Vec>> = + zcash_deserialize_external_count(actions.len(), &mut reader)?; - // Denoted as `vAssetBurn` in the spec (ZIP 230). - let burn = (&mut reader).zcash_deserialize_into()?; + // Create the AuthorizedAction from deserialized parts + let authorized_actions: Vec> = actions + .into_iter() + .zip(spend_sigs) + .map(|(action, spend_sig)| { + orchard::AuthorizedAction::from_parts(action, spend_sig.into_signature()) + }) + .collect(); - // Denoted as `sizeProofsOrchard` and `proofsOrchard` in the spec. - // Consensus: type is `ZKAction.Proof`, i.e. a byte sequence. - // https://zips.z.cash/protocol/protocol.pdf#halo2encoding - let proof: Halo2Proof = (&mut reader).zcash_deserialize_into()?; + let actions: AtLeastOne> = + authorized_actions.try_into()?; - // Denoted as `vSpendAuthSigsOrchard` in the spec. - let spend_sigs: Vec>> = - zcash_deserialize_external_count(actions.len(), &mut reader)?; + action_groups.push(ActionGroup { + flags, + shared_anchor, + expiry_height, + burn, + proof, + actions, + }) + } // Denoted as `valueBalanceOrchard` in the spec. let value_balance: Amount = (&mut reader).zcash_deserialize_into()?; @@ -581,25 +637,9 @@ impl ZcashDeserialize for Option> { let binding_sig: Signature = VersionedSigV0::zcash_deserialize(&mut reader)?.into_signature(); - // Create the AuthorizedAction from deserialized parts - let authorized_actions: Vec> = actions - .into_iter() - .zip(spend_sigs) - .map(|(action, spend_sig)| { - orchard::AuthorizedAction::from_parts(action, spend_sig.into_signature()) - }) - .collect(); - - let actions: AtLeastOne> = - authorized_actions.try_into()?; - Ok(Some(orchard::ShieldedData:: { - flags, + action_groups: action_groups.try_into()?, value_balance, - burn, - shared_anchor, - proof, - actions, binding_sig, })) } diff --git a/zebra-chain/src/transaction/tests/vectors.rs b/zebra-chain/src/transaction/tests/vectors.rs index 636813236c6..406ec0eccda 100644 --- a/zebra-chain/src/transaction/tests/vectors.rs +++ b/zebra-chain/src/transaction/tests/vectors.rs @@ -491,8 +491,9 @@ fn v6_round_trip() { let _init_guard = zebra_test::init(); - for block_bytes in ORCHARD_ZSA_WORKFLOW_BLOCKS.iter() { - let block = block_bytes + for workflow_block in ORCHARD_ZSA_WORKFLOW_BLOCKS.iter() { + let block = workflow_block + .bytes .zcash_deserialize_into::() .expect("block is structurally valid"); @@ -502,7 +503,7 @@ fn v6_round_trip() { .expect("vec serialization is infallible"); assert_eq!( - block_bytes, &block_bytes2, + workflow_block.bytes, block_bytes2, "data must be equal if structs are equal" ); @@ -638,8 +639,9 @@ fn v6_librustzcash_tx_conversion() { let _init_guard = zebra_test::init(); - for block_bytes in ORCHARD_ZSA_WORKFLOW_BLOCKS.iter() { - let block = block_bytes + for workflow_block in ORCHARD_ZSA_WORKFLOW_BLOCKS.iter() { + let block = workflow_block + .bytes .zcash_deserialize_into::() .expect("block is structurally valid"); diff --git a/zebra-consensus/Cargo.toml b/zebra-consensus/Cargo.toml index 56495ba0526..13e027732db 100644 --- a/zebra-consensus/Cargo.toml +++ b/zebra-consensus/Cargo.toml @@ -40,6 +40,12 @@ tx_v6 = [ "zebra-chain/tx_v6" ] +# Support for ZSA asset swaps +zsa-swap = [ + "zebra-state/zsa-swap", + "zebra-chain/zsa-swap" +] + [dependencies] blake2b_simd = "1.0.2" bellman = "0.14.0" diff --git a/zebra-consensus/src/block.rs b/zebra-consensus/src/block.rs index 611aea2ceba..207a202f6ea 100644 --- a/zebra-consensus/src/block.rs +++ b/zebra-consensus/src/block.rs @@ -256,20 +256,22 @@ where 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, + } = 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; } } diff --git a/zebra-consensus/src/orchard_zsa/tests.rs b/zebra-consensus/src/orchard_zsa/tests.rs index 4100c3be9c1..60a0bb74f45 100644 --- a/zebra-consensus/src/orchard_zsa/tests.rs +++ b/zebra-consensus/src/orchard_zsa/tests.rs @@ -1,3 +1,7 @@ +// FIXME: Consider moving thesr tests to `zebra-consensus/src/router/tests.rs`, or creating a +// `zebra-consensus/src/router/tests` directory and placing this module with a name +// 'orchard_zsa` there. + //! Simulates a full Zebra node’s block‐processing pipeline on a predefined Orchard/ZSA workflow. //! //! This integration test reads a sequence of serialized regtest blocks (including Orchard burns @@ -12,50 +16,261 @@ //! In short, it demonstrates end-to-end handling of Orchard asset burns and ZSA issuance through //! consensus (with state verification to follow in the next PR). -use std::sync::Arc; +use std::{ + collections::{hash_map, HashMap}, + sync::Arc, +}; use color_eyre::eyre::{eyre, Report}; use tokio::time::{timeout, Duration}; +use tower::ServiceExt; + +use orchard::{ + issuance::{AssetRecord, IssueAction}, + issuance_auth::{IssueValidatingKey, ZSASchnorr}, + note::AssetBase, + value::NoteValue, +}; use zebra_chain::{ block::{genesis::regtest_genesis_block, Block, Hash}, + orchard_zsa::{AssetState, BurnItem}, parameters::Network, serialization::ZcashDeserialize, }; +use zebra_state::{ReadRequest, ReadResponse, ReadStateService}; + use zebra_test::{ transcript::{ExpectedTranscriptError, Transcript}, - vectors::ORCHARD_ZSA_WORKFLOW_BLOCKS, + vectors::{OrchardWorkflowBlock, ORCHARD_ZSA_WORKFLOW_BLOCKS}, }; use crate::{block::Request, Config}; -fn create_transcript_data() -> impl Iterator)> -{ - let workflow_blocks = ORCHARD_ZSA_WORKFLOW_BLOCKS.iter().map(|block_bytes| { - Arc::new(Block::zcash_deserialize(&block_bytes[..]).expect("block should deserialize")) - }); +type AssetRecords = HashMap; + +type TranscriptItem = (Request, Result); + +#[derive(Debug)] +enum AssetRecordsError { + BurnAssetMissing, + EmptyActionNotFinalized, + AmountOverflow, + MissingRefNote, + ModifyFinalized, +} + +/// Processes orchard burns, decreasing asset supply. +fn process_burns<'a, I: Iterator>( + asset_records: &mut AssetRecords, + burns: I, +) -> Result<(), AssetRecordsError> { + for burn in burns { + // FIXME: check for burn specific errors? + let asset_record = asset_records + .get_mut(&burn.asset()) + .ok_or(AssetRecordsError::BurnAssetMissing)?; + + asset_record.amount = NoteValue::from_raw( + asset_record + .amount + .inner() + .checked_sub(burn.amount().inner()) + .ok_or(AssetRecordsError::AmountOverflow)?, + ); + } + + Ok(()) +} + +/// Processes orchard issue actions, increasing asset supply. +fn process_issue_actions<'a, I: Iterator>( + asset_records: &mut AssetRecords, + ik: &IssueValidatingKey, + actions: I, +) -> Result<(), AssetRecordsError> { + for action in actions { + let action_asset = AssetBase::derive(ik, action.asset_desc_hash()); + let reference_note = action.get_reference_note(); + let is_finalized = action.is_finalized(); + + let mut note_amounts = action.notes().iter().map(|note| { + if note.asset() == action_asset { + Ok(note.value()) + } else { + Err(AssetRecordsError::BurnAssetMissing) + } + }); + + let first_note_amount = match note_amounts.next() { + Some(note_amount) => note_amount, + None => { + if is_finalized { + Ok(NoteValue::from_raw(0)) + } else { + Err(AssetRecordsError::EmptyActionNotFinalized) + } + } + }; + + for amount_result in std::iter::once(first_note_amount).chain(note_amounts) { + let amount = amount_result?; + + // FIXME: check for issuance specific errors? + match asset_records.entry(action_asset) { + hash_map::Entry::Occupied(mut entry) => { + let asset_record = entry.get_mut(); + asset_record.amount = + (asset_record.amount + amount).ok_or(AssetRecordsError::AmountOverflow)?; + if asset_record.is_finalized { + return Err(AssetRecordsError::ModifyFinalized); + } + asset_record.is_finalized = is_finalized; + } + + hash_map::Entry::Vacant(entry) => { + entry.insert(AssetRecord { + amount, + is_finalized, + reference_note: *reference_note.ok_or(AssetRecordsError::MissingRefNote)?, + }); + } + } + } + } - std::iter::once(regtest_genesis_block()) + Ok(()) +} + +/// Builds assets records for the given blocks. +fn build_asset_records<'a, I: IntoIterator>( + blocks: I, +) -> Result { + blocks + .into_iter() + .filter_map(|(request, result)| match (request, result) { + (Request::Commit(block), Ok(_)) => Some(&block.transactions), + _ => None, + }) + .flatten() + .try_fold(HashMap::new(), |mut asset_records, tx| { + process_burns(&mut asset_records, tx.orchard_burns())?; + + if let Some(issue_data) = tx.orchard_issue_data() { + process_issue_actions( + &mut asset_records, + issue_data.inner().ik(), + issue_data.actions(), + )?; + } + + Ok(asset_records) + }) +} + +/// Creates transcript data from predefined workflow blocks. +fn create_transcript_data<'a, I: IntoIterator>( + serialized_blocks: I, +) -> impl Iterator + use<'a, I> { + let workflow_blocks = + serialized_blocks + .into_iter() + .map(|OrchardWorkflowBlock { bytes, is_valid }| { + ( + Arc::new( + Block::zcash_deserialize(&bytes[..]).expect("block should deserialize"), + ), + *is_valid, + ) + }); + + std::iter::once((regtest_genesis_block(), true)) .chain(workflow_blocks) - .map(|block| (Request::Commit(block.clone()), Ok(block.hash()))) + .map(|(block, is_valid)| { + ( + Request::Commit(block.clone()), + if is_valid { + Ok(block.hash()) + } else { + Err(ExpectedTranscriptError::Any) + }, + ) + }) +} + +/// Queries the state service for the asset state of the given asset. +async fn request_asset_state( + read_state_service: &ReadStateService, + asset_base: AssetBase, +) -> Option { + let request = ReadRequest::AssetState { + asset_base, + include_non_finalized: true, + }; + + match read_state_service.clone().oneshot(request).await { + Ok(ReadResponse::AssetState(asset_state)) => asset_state, + _ => unreachable!("The state service returned an unexpected response."), + } } #[tokio::test(flavor = "multi_thread")] -async fn check_zsa_workflow() -> Result<(), Report> { +async fn check_orchard_zsa_workflow() -> Result<(), Report> { let _init_guard = zebra_test::init(); let network = Network::new_regtest(Some(1), Some(1), Some(1)); - let state_service = zebra_state::init_test(&network); + let (state_service, read_state_service, _, _) = zebra_state::init_test_services(&network); let (block_verifier_router, ..) = crate::router::init(Config::default(), &network, state_service).await; + let transcript_data = + create_transcript_data(ORCHARD_ZSA_WORKFLOW_BLOCKS.iter()).collect::>(); + + let asset_records = + build_asset_records(&transcript_data).expect("should calculate asset_records"); + + // Before applying the blocks, ensure that none of the assets exist in the state. + for &asset_base in asset_records.keys() { + assert!( + request_asset_state(&read_state_service, asset_base) + .await + .is_none(), + "State should initially have no info about this asset." + ); + } + + // Verify all blocks in the transcript against the consensus and the state. timeout( Duration::from_secs(15), - Transcript::from(create_transcript_data()).check(block_verifier_router), + Transcript::from(transcript_data).check(block_verifier_router), ) .await - .map_err(|_| eyre!("Task timed out"))? + .map_err(|_| eyre!("Task timed out"))??; + + // After processing the transcript blocks, verify that the state matches the expected supply info. + for (&asset_base, asset_record) in &asset_records { + let asset_state = request_asset_state(&read_state_service, asset_base) + .await + .expect("State should contain this asset now."); + + assert_eq!( + asset_state.is_finalized, asset_record.is_finalized, + "Finalized state does not match for asset {:?}.", + asset_base + ); + + assert_eq!( + asset_state.total_supply, + // FIXME: Fix it after chaning ValueSum to NoteValue in AssetSupply in orchard + u64::try_from(i128::from(asset_record.amount)) + .expect("asset supply amount should be within u64 range"), + "Total supply mismatch for asset {:?}.", + asset_base + ); + } + + Ok(()) } diff --git a/zebra-consensus/src/primitives/halo2.rs b/zebra-consensus/src/primitives/halo2.rs index 691bfaede62..8a0e71be596 100644 --- a/zebra-consensus/src/primitives/halo2.rs +++ b/zebra-consensus/src/primitives/halo2.rs @@ -19,7 +19,7 @@ use tower::{util::ServiceFn, Service}; use tower_batch_control::{Batch, BatchControl}; use tower_fallback::Fallback; -use zebra_chain::orchard::{OrchardVanilla, ShieldedData, ShieldedDataFlavor}; +use zebra_chain::orchard::{ActionGroup, OrchardVanilla, ShieldedDataFlavor}; #[cfg(feature = "tx_v6")] use zebra_chain::orchard::OrchardZSA; @@ -141,16 +141,16 @@ impl BatchVerifier { // === END TEMPORARY BATCH HALO2 SUBSTITUTE === -impl From<&ShieldedData> for Item { - fn from(shielded_data: &ShieldedData) -> Item { +impl From<&ActionGroup> for Item { + fn from(action_group: &ActionGroup) -> Item { use orchard::{circuit, note, primitives::redpallas, tree, value}; - let anchor = tree::Anchor::from_bytes(shielded_data.shared_anchor.into()).unwrap(); + let anchor = tree::Anchor::from_bytes(action_group.shared_anchor.into()).unwrap(); - let flags = orchard::bundle::Flags::from_byte(shielded_data.flags.bits()) - .expect("failed to convert flags: shielded_data.flags contains unexpected bits that are not valid in orchard::bundle::Flags"); + let flags = orchard::bundle::Flags::from_byte(action_group.flags.bits()) + .expect("failed to convert flags: action_group.flags contains unexpected bits that are not valid in orchard::bundle::Flags"); - let instances = shielded_data + let instances = action_group .actions() .map(|action| { circuit::Instance::from_parts( @@ -169,7 +169,7 @@ impl From<&ShieldedData> for Item { Item { instances, - proof: orchard::circuit::Proof::new(shielded_data.proof.0.clone()), + proof: orchard::circuit::Proof::new(action_group.proof.0.clone()), } } } diff --git a/zebra-consensus/src/primitives/halo2/tests.rs b/zebra-consensus/src/primitives/halo2/tests.rs index 1b2da85af00..12915449aa7 100644 --- a/zebra-consensus/src/primitives/halo2/tests.rs +++ b/zebra-consensus/src/primitives/halo2/tests.rs @@ -18,10 +18,13 @@ use orchard::{ use rand::rngs::OsRng; use zebra_chain::{ - orchard::{ShieldedData, ShieldedDataFlavor}, - serialization::{ZcashDeserialize, ZcashDeserializeInto, ZcashSerialize}, + orchard::{OrchardVanilla, ShieldedData, ShieldedDataFlavor}, + serialization::{AtLeastOne, ZcashDeserialize, ZcashDeserializeInto, ZcashSerialize}, }; +#[cfg(feature = "tx_v6")] +use zebra_chain::orchard::OrchardZSA; + use crate::primitives::halo2::*; #[allow(dead_code, clippy::print_stdout)] @@ -77,42 +80,47 @@ where .unwrap(); ShieldedData:: { - flags, + action_groups: AtLeastOne::from_one(ActionGroup { + flags, + shared_anchor: anchor_bytes.try_into().unwrap(), + // FIXME: use a proper value below if needed + #[cfg(feature = "tx_v6")] + expiry_height: 0, + #[cfg(feature = "tx_v6")] + burn: bundle.burn().as_slice().into(), + proof: zebra_chain::primitives::Halo2Proof( + bundle.authorization().proof().as_ref().into(), + ), + actions: bundle + .actions() + .iter() + .map(|a| { + let action = zebra_chain::orchard::Action:: { + cv: a.cv_net().to_bytes().try_into().unwrap(), + nullifier: a.nullifier().to_bytes().try_into().unwrap(), + rk: <[u8; 32]>::from(a.rk()).into(), + cm_x: pallas::Base::from_repr(a.cmx().into()).unwrap(), + ephemeral_key: a.encrypted_note().epk_bytes.try_into().unwrap(), + enc_ciphertext: a + .encrypted_note() + .enc_ciphertext + .as_ref() + .try_into() + .unwrap(), + out_ciphertext: a.encrypted_note().out_ciphertext.into(), + }; + zebra_chain::orchard::shielded_data::AuthorizedAction { + action, + spend_auth_sig: <[u8; 64]>::from(a.authorization().sig()).into(), + } + }) + .collect::>() + .try_into() + .unwrap(), + }), value_balance: note_value.try_into().unwrap(), - shared_anchor: anchor_bytes.try_into().unwrap(), - proof: zebra_chain::primitives::Halo2Proof( - bundle.authorization().proof().as_ref().into(), - ), - actions: bundle - .actions() - .iter() - .map(|a| { - let action = zebra_chain::orchard::Action:: { - cv: a.cv_net().to_bytes().try_into().unwrap(), - nullifier: a.nullifier().to_bytes().try_into().unwrap(), - rk: <[u8; 32]>::from(a.rk()).into(), - cm_x: pallas::Base::from_repr(a.cmx().into()).unwrap(), - ephemeral_key: a.encrypted_note().epk_bytes.try_into().unwrap(), - enc_ciphertext: a - .encrypted_note() - .enc_ciphertext - .as_ref() - .try_into() - .unwrap(), - out_ciphertext: a.encrypted_note().out_ciphertext.into(), - }; - zebra_chain::orchard::shielded_data::AuthorizedAction { - action, - spend_auth_sig: <[u8; 64]>::from(a.authorization().sig()).into(), - } - }) - .collect::>() - .try_into() - .unwrap(), binding_sig: <[u8; 64]>::from(bundle.authorization().binding_signature().sig()) .into(), - #[cfg(feature = "tx_v6")] - burn: bundle.burn().as_slice().into(), } }) .collect(); @@ -136,11 +144,13 @@ where let mut async_checks = FuturesUnordered::new(); for sd in shielded_data { - tracing::trace!(?sd); + for ag in sd.action_groups { + tracing::trace!(?ag); - let rsp = verifier.ready().await?.call(Item::from(&sd)); + let rsp = verifier.ready().await?.call(Item::from(&ag)); - async_checks.push(rsp); + async_checks.push(rsp); + } } while let Some(result) = async_checks.next().await { @@ -216,17 +226,19 @@ where let mut async_checks = FuturesUnordered::new(); for sd in shielded_data { - let mut sd = sd.clone(); + for ag in sd.action_groups { + let mut ag = ag.clone(); - sd.flags.remove(zebra_chain::orchard::Flags::ENABLE_SPENDS); - sd.flags.remove(zebra_chain::orchard::Flags::ENABLE_OUTPUTS); - // FIXME: What about zebra_chain::orchard::Flags::ENABLE_ZSA? + ag.flags.remove(zebra_chain::orchard::Flags::ENABLE_SPENDS); + ag.flags.remove(zebra_chain::orchard::Flags::ENABLE_OUTPUTS); + // FIXME: What about zebra_chain::orchard::Flags::ENABLE_ZSA? - tracing::trace!(?sd); + tracing::trace!(?ag); - let rsp = verifier.ready().await?.call(Item::from(&sd)); + let rsp = verifier.ready().await?.call(Item::from(&ag)); - async_checks.push(rsp); + async_checks.push(rsp); + } } while let Some(result) = async_checks.next().await { diff --git a/zebra-consensus/src/router/tests.rs b/zebra-consensus/src/router/tests.rs index 8fe304e3364..97d970ebb0f 100644 --- a/zebra-consensus/src/router/tests.rs +++ b/zebra-consensus/src/router/tests.rs @@ -270,3 +270,54 @@ async fn verify_fail_add_block_checkpoint() -> Result<(), Report> { Ok(()) } + +// FIXME: Consider removing this test. The more comprehensive `check_orchard_zsa_workflow` +// in `zebra-consensus/src/orchard_zsa/tests.rs` already verifies everything this test +// covers (and more). +// FIXME: This test is commented out because it fails when running in CI (locally, it passes) +// - This situation needs to be figured out, as it may reflect an error in the main code. +/* +#[tokio::test(flavor = "multi_thread")] +async fn verify_issuance_blocks_test() -> Result<(), Report> { + use block::genesis::regtest_genesis_block; + + use zebra_test::vectors::{OrchardWorkflowBlock, ORCHARD_ZSA_WORKFLOW_BLOCKS}; + + 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 = + ORCHARD_ZSA_WORKFLOW_BLOCKS + .iter() + .map(|OrchardWorkflowBlock { bytes, is_valid }| { + let block = Arc::new( + Block::zcash_deserialize(&bytes[..]).expect("block should deserialize"), + ); + ( + Request::Commit(block.clone()), + if *is_valid { + Ok(block.hash()) + } else { + Err(ExpectedTranscriptError::Any) + }, + ) + }); + + 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 4e134f7f59e..9af5cb77aca 100644 --- a/zebra-consensus/src/transaction.rs +++ b/zebra-consensus/src/transaction.rs @@ -1056,6 +1056,7 @@ where let mut async_checks = AsyncChecks::new(); if let Some(orchard_shielded_data) = orchard_shielded_data { + // FIXME: update the comment to describe action groups // # Consensus // // > The proof 𝜋 MUST be valid given a primary input (cv, rt^{Orchard}, @@ -1067,13 +1068,15 @@ where // aggregated Halo2 proof per transaction, even with multiple // Actions in one transaction. So we queue it for verification // only once instead of queuing it up for every Action description. - async_checks.push( - V::get_verifier() - .clone() - .oneshot(primitives::halo2::Item::from(orchard_shielded_data)), - ); + for action_group in orchard_shielded_data.action_groups.iter() { + async_checks.push( + V::get_verifier() + .clone() + .oneshot(primitives::halo2::Item::from(action_group)), + ) + } - for authorized_action in orchard_shielded_data.actions.iter().cloned() { + for authorized_action in orchard_shielded_data.authorized_actions().cloned() { let (action, spend_auth_sig) = authorized_action.into_parts(); // # Consensus diff --git a/zebra-consensus/src/transaction/check.rs b/zebra-consensus/src/transaction/check.rs index 133c7e80470..1906c634ec1 100644 --- a/zebra-consensus/src/transaction/check.rs +++ b/zebra-consensus/src/transaction/check.rs @@ -172,7 +172,8 @@ pub fn coinbase_tx_no_prevout_joinsplit_spend(tx: &Transaction) -> Result<(), Tr return Err(TransactionError::CoinbaseHasSpend); } - if let Some(orchard_flags) = tx.orchard_flags() { + // FIXME: is it correct to use orchard_flags_union here? + if let Some(orchard_flags) = tx.orchard_flags_union() { if orchard_flags.contains(Flags::ENABLE_SPENDS) { return Err(TransactionError::CoinbaseHasEnableSpendsOrchard); } diff --git a/zebra-consensus/src/transaction/tests.rs b/zebra-consensus/src/transaction/tests.rs index 06aa7040b1c..06bc30015a5 100644 --- a/zebra-consensus/src/transaction/tests.rs +++ b/zebra-consensus/src/transaction/tests.rs @@ -88,7 +88,7 @@ fn fake_v5_transaction_with_orchard_actions_has_inputs_and_outputs() { // If we add ENABLE_SPENDS flag it will pass the inputs check but fails with the outputs // TODO: Avoid new calls to `insert_fake_orchard_shielded_data` for each check #2409. let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); - shielded_data.flags = zebra_chain::orchard::Flags::ENABLE_SPENDS; + shielded_data.action_groups.first_mut().flags = zebra_chain::orchard::Flags::ENABLE_SPENDS; assert_eq!( check::has_inputs_and_outputs(&transaction), @@ -97,7 +97,7 @@ fn fake_v5_transaction_with_orchard_actions_has_inputs_and_outputs() { // If we add ENABLE_OUTPUTS flag it will pass the outputs check but fails with the inputs let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); - shielded_data.flags = zebra_chain::orchard::Flags::ENABLE_OUTPUTS; + shielded_data.action_groups.first_mut().flags = zebra_chain::orchard::Flags::ENABLE_OUTPUTS; assert_eq!( check::has_inputs_and_outputs(&transaction), @@ -106,7 +106,7 @@ fn fake_v5_transaction_with_orchard_actions_has_inputs_and_outputs() { // Finally make it valid by adding both required flags let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); - shielded_data.flags = + shielded_data.action_groups.first_mut().flags = zebra_chain::orchard::Flags::ENABLE_SPENDS | zebra_chain::orchard::Flags::ENABLE_OUTPUTS; assert!(check::has_inputs_and_outputs(&transaction).is_ok()); @@ -141,17 +141,17 @@ fn fake_v5_transaction_with_orchard_actions_has_flags() { // If we add ENABLE_SPENDS flag it will pass. let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); - shielded_data.flags = zebra_chain::orchard::Flags::ENABLE_SPENDS; + shielded_data.action_groups.first_mut().flags = zebra_chain::orchard::Flags::ENABLE_SPENDS; assert!(check::has_enough_orchard_flags(&transaction).is_ok()); // If we add ENABLE_OUTPUTS flag instead, it will pass. let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); - shielded_data.flags = zebra_chain::orchard::Flags::ENABLE_OUTPUTS; + shielded_data.action_groups.first_mut().flags = zebra_chain::orchard::Flags::ENABLE_OUTPUTS; assert!(check::has_enough_orchard_flags(&transaction).is_ok()); // If we add BOTH ENABLE_SPENDS and ENABLE_OUTPUTS flags it will pass. let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); - shielded_data.flags = + shielded_data.action_groups.first_mut().flags = zebra_chain::orchard::Flags::ENABLE_SPENDS | zebra_chain::orchard::Flags::ENABLE_OUTPUTS; assert!(check::has_enough_orchard_flags(&transaction).is_ok()); } @@ -840,7 +840,7 @@ fn v5_coinbase_transaction_with_enable_spends_flag_fails_validation() { let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); - shielded_data.flags = zebra_chain::orchard::Flags::ENABLE_SPENDS; + shielded_data.action_groups.first_mut().flags = zebra_chain::orchard::Flags::ENABLE_SPENDS; assert_eq!( check::coinbase_tx_no_prevout_joinsplit_spend(&transaction), @@ -2418,14 +2418,18 @@ fn v5_with_duplicate_orchard_action() { let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); // Enable spends - shielded_data.flags = zebra_chain::orchard::Flags::ENABLE_SPENDS + shielded_data.action_groups.first_mut().flags = zebra_chain::orchard::Flags::ENABLE_SPENDS | zebra_chain::orchard::Flags::ENABLE_OUTPUTS; // Duplicate the first action - let duplicate_action = shielded_data.actions.first().clone(); + let duplicate_action = shielded_data.action_groups.first().actions.first().clone(); let duplicate_nullifier = duplicate_action.action.nullifier; - shielded_data.actions.push(duplicate_action); + shielded_data + .action_groups + .first_mut() + .actions + .push(duplicate_action); // Initialize the verifier let state_service = @@ -2866,15 +2870,18 @@ fn coinbase_outputs_are_decryptable_for_fake_v5_blocks() { .expect("At least one fake V5 transaction with no inputs and no outputs"); let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); - shielded_data.flags = zebra_chain::orchard::Flags::ENABLE_SPENDS + shielded_data.action_groups.first_mut().flags = zebra_chain::orchard::Flags::ENABLE_SPENDS | zebra_chain::orchard::Flags::ENABLE_OUTPUTS; - let action = - fill_action_with_note_encryption_test_vector(&shielded_data.actions[0].action, v); - let sig = shielded_data.actions[0].spend_auth_sig; - shielded_data.actions = vec![AuthorizedAction::from_parts(action, sig)] - .try_into() - .unwrap(); + let action = fill_action_with_note_encryption_test_vector( + &shielded_data.action_groups.first().actions[0].action, + v, + ); + let sig = shielded_data.action_groups.first().actions[0].spend_auth_sig; + shielded_data.action_groups.first_mut().actions = + vec![AuthorizedAction::from_parts(action, sig)] + .try_into() + .unwrap(); assert_eq!( check::coinbase_outputs_are_decryptable( @@ -2908,15 +2915,18 @@ fn shielded_outputs_are_not_decryptable_for_fake_v5_blocks() { .expect("At least one fake V5 transaction with no inputs and no outputs"); let shielded_data = insert_fake_orchard_shielded_data(&mut transaction); - shielded_data.flags = zebra_chain::orchard::Flags::ENABLE_SPENDS + shielded_data.action_groups.first_mut().flags = zebra_chain::orchard::Flags::ENABLE_SPENDS | zebra_chain::orchard::Flags::ENABLE_OUTPUTS; - let action = - fill_action_with_note_encryption_test_vector(&shielded_data.actions[0].action, v); - let sig = shielded_data.actions[0].spend_auth_sig; - shielded_data.actions = vec![AuthorizedAction::from_parts(action, sig)] - .try_into() - .unwrap(); + let action = fill_action_with_note_encryption_test_vector( + &shielded_data.action_groups.first().actions[0].action, + v, + ); + let sig = shielded_data.action_groups.first().actions[0].spend_auth_sig; + shielded_data.action_groups.first_mut().actions = + vec![AuthorizedAction::from_parts(action, sig)] + .try_into() + .unwrap(); assert_eq!( check::coinbase_outputs_are_decryptable( diff --git a/zebra-rpc/src/methods.rs b/zebra-rpc/src/methods.rs index 8becc5bb79c..f38a6e4108f 100644 --- a/zebra-rpc/src/methods.rs +++ b/zebra-rpc/src/methods.rs @@ -23,7 +23,7 @@ use zebra_chain::{ block::{self, Height, SerializedBlock}, chain_tip::{ChainTip, NetworkChainTipHeightEstimator}, parameters::{ConsensusBranchId, Network, NetworkUpgrade}, - serialization::ZcashDeserialize, + serialization::{ZcashDeserialize, ZcashDeserializeInto}, subtree::NoteCommitmentSubtreeIndex, transaction::{self, SerializedTransaction, Transaction, UnminedTx}, transparent::{self, Address}, @@ -302,6 +302,17 @@ pub trait Rpc { address_strings: AddressStrings, ) -> BoxFuture>>; + /// Returns the asset state of the provided asset base at the best chain tip or finalized chain tip. + /// + /// method: post + /// tags: blockchain + #[rpc(name = "getassetstate")] + fn get_asset_state( + &self, + asset_base: String, + include_non_finalized: Option, + ) -> BoxFuture>; + /// Stop the running zebrad process. /// /// # Notes @@ -1358,6 +1369,36 @@ where .boxed() } + fn get_asset_state( + &self, + asset_base: String, + include_non_finalized: Option, + ) -> BoxFuture> { + let state = self.state.clone(); + let include_non_finalized = include_non_finalized.unwrap_or(true); + + async move { + let asset_base = hex::decode(asset_base) + .map_server_error()? + .zcash_deserialize_into() + .map_server_error()?; + + let request = zebra_state::ReadRequest::AssetState { + asset_base, + include_non_finalized, + }; + + let zebra_state::ReadResponse::AssetState(asset_state) = + state.oneshot(request).await.map_server_error()? + else { + unreachable!("unexpected response from state service"); + }; + + asset_state.ok_or_server_error("asset base not found") + } + .boxed() + } + fn stop(&self) -> Result { #[cfg(not(target_os = "windows"))] if self.network.is_regtest() { diff --git a/zebra-rpc/src/methods/tests/snapshot.rs b/zebra-rpc/src/methods/tests/snapshot.rs index f4d7804088e..fe9e9cccd7f 100644 --- a/zebra-rpc/src/methods/tests/snapshot.rs +++ b/zebra-rpc/src/methods/tests/snapshot.rs @@ -14,6 +14,7 @@ use zebra_chain::{ block::Block, chain_tip::mock::MockChainTip, orchard, + orchard_zsa::{asset_state::RandomAssetBase, AssetBase, AssetState}, parameters::{ subsidy::POST_NU6_FUNDING_STREAMS_TESTNET, testnet::{self, ConfiguredActivationHeights, Parameters}, @@ -536,6 +537,41 @@ async fn test_mocked_rpc_response_data_for_network(network: &Network) { settings.bind(|| { insta::assert_json_snapshot!(format!("z_get_subtrees_by_index_for_orchard"), subtrees) }); + + // Test the response format from `getassetstate`. + + // Prepare the state response and make the RPC request. + let rsp = state + .expect_request_that(|req| matches!(req, ReadRequest::AssetState { .. })) + .map(|responder| responder.respond(ReadResponse::AssetState(None))); + let req = rpc.get_asset_state(AssetBase::random_serialized(), None); + + // Get the RPC error response. + let (asset_state_rsp, ..) = tokio::join!(req, rsp); + let asset_state = asset_state_rsp.expect_err("The RPC response should be an error"); + + // Check the error response. + settings + .bind(|| insta::assert_json_snapshot!(format!("get_asset_state_not_found"), asset_state)); + + // Prepare the state response and make the RPC request. + let rsp = state + .expect_request_that(|req| matches!(req, ReadRequest::AssetState { .. })) + .map(|responder| { + responder.respond(ReadResponse::AssetState(Some(AssetState { + is_finalized: true, + total_supply: 1000, + }))) + }); + let req = rpc.get_asset_state(AssetBase::random_serialized(), None); + + // Get the RPC response. + let (asset_state_rsp, ..) = tokio::join!(req, rsp); + let asset_state = + asset_state_rsp.expect("The RPC response should contain a `AssetState` struct."); + + // Check the response. + settings.bind(|| insta::assert_json_snapshot!(format!("get_asset_state"), asset_state)); } /// Snapshot `getinfo` response, using `cargo insta` and JSON serialization. diff --git a/zebra-rpc/src/methods/tests/snapshots/get_asset_state@mainnet.snap b/zebra-rpc/src/methods/tests/snapshots/get_asset_state@mainnet.snap new file mode 100644 index 00000000000..9085ab62c88 --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_asset_state@mainnet.snap @@ -0,0 +1,8 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +expression: asset_state +--- +{ + "is_finalized": true, + "total_supply": 1000 +} diff --git a/zebra-rpc/src/methods/tests/snapshots/get_asset_state@testnet.snap b/zebra-rpc/src/methods/tests/snapshots/get_asset_state@testnet.snap new file mode 100644 index 00000000000..9085ab62c88 --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_asset_state@testnet.snap @@ -0,0 +1,8 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +expression: asset_state +--- +{ + "is_finalized": true, + "total_supply": 1000 +} diff --git a/zebra-rpc/src/methods/tests/snapshots/get_asset_state_not_found@mainnet.snap b/zebra-rpc/src/methods/tests/snapshots/get_asset_state_not_found@mainnet.snap new file mode 100644 index 00000000000..9efcfd5868f --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_asset_state_not_found@mainnet.snap @@ -0,0 +1,8 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +expression: asset_state +--- +{ + "code": 0, + "message": "asset base not found" +} diff --git a/zebra-rpc/src/methods/tests/snapshots/get_asset_state_not_found@testnet.snap b/zebra-rpc/src/methods/tests/snapshots/get_asset_state_not_found@testnet.snap new file mode 100644 index 00000000000..9efcfd5868f --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_asset_state_not_found@testnet.snap @@ -0,0 +1,8 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +expression: asset_state +--- +{ + "code": 0, + "message": "asset base not found" +} 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/Cargo.toml b/zebra-state/Cargo.toml index f341e26cde3..aba8657d1cd 100644 --- a/zebra-state/Cargo.toml +++ b/zebra-state/Cargo.toml @@ -51,6 +51,11 @@ tx_v6 = [ "zebra-chain/tx_v6" ] +# Support for ZSA asset swaps +zsa-swap = [ + "zebra-chain/zsa-swap" +] + [dependencies] bincode = "1.3.3" chrono = { version = "0.4.38", default-features = false, features = ["clock", "std"] } diff --git a/zebra-state/src/arbitrary.rs b/zebra-state/src/arbitrary.rs index 5c0b837566a..c731334b210 100644 --- a/zebra-state/src/arbitrary.rs +++ b/zebra-state/src/arbitrary.rs @@ -96,8 +96,13 @@ 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, + #[cfg(feature = "tx_v6")] + Default::default(), + ) + .expect("all UTXOs are provided with zero values") } /// Create a [`ContextuallyVerifiedBlock`] from a [`Block`] or [`SemanticallyVerifiedBlock`], @@ -125,6 +130,8 @@ impl ContextuallyVerifiedBlock { spent_outputs: new_outputs, transaction_hashes, chain_value_pool_change: ValueBalance::zero(), + #[cfg(feature = "tx_v6")] + 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..fc6a9ba1475 100644 --- a/zebra-state/src/lib.rs +++ b/zebra-state/src/lib.rs @@ -44,6 +44,10 @@ pub use error::{ pub use request::{ CheckpointVerifiedBlock, HashOrHeight, ReadRequest, Request, SemanticallyVerifiedBlock, }; + +#[cfg(feature = "tx_v6")] +pub use request::IssuedAssetsOrChange; + pub use response::{KnownBlock, MinedTx, ReadResponse, Response}; pub use service::{ chain_tip::{ChainTipBlock, ChainTipChange, ChainTipSender, LatestChainTip, TipAction}, diff --git a/zebra-state/src/request.rs b/zebra-state/src/request.rs index 56be011d48e..a4c6ad5f78f 100644 --- a/zebra-state/src/request.rs +++ b/zebra-state/src/request.rs @@ -21,6 +21,9 @@ use zebra_chain::{ value_balance::{ValueBalance, ValueBalanceError}, }; +#[cfg(feature = "tx_v6")] +use zebra_chain::orchard_zsa::{AssetBase, IssuedAssets, IssuedAssetsChange}; + /// Allow *only* these unused imports, so that rustdoc link resolution /// will work with inline links. #[allow(unused_imports)] @@ -223,6 +226,11 @@ pub struct ContextuallyVerifiedBlock { /// The sum of the chain value pool changes of all transactions in this block. pub(crate) chain_value_pool_change: ValueBalance, + + #[cfg(feature = "tx_v6")] + /// 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,42 @@ pub struct FinalizedBlock { pub(super) treestate: Treestate, /// This block's contribution to the deferred pool. pub(super) deferred_balance: Option>, + + #[cfg(feature = "tx_v6")] + /// Updated asset states to be inserted into the finalized state, replacing the previous + /// asset states for those asset bases. + pub issued_assets: Option, +} + +#[cfg(feature = "tx_v6")] +/// 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), +} + +#[cfg(feature = "tx_v6")] +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) + Self::from_semantically_verified( + SemanticallyVerifiedBlock::from(block), + treestate, + #[cfg(feature = "tx_v6")] + None, + ) } /// Constructs [`FinalizedBlock`] from [`ContextuallyVerifiedBlock`] and its [`Treestate`]. @@ -306,11 +344,22 @@ impl FinalizedBlock { block: ContextuallyVerifiedBlock, treestate: Treestate, ) -> Self { - Self::from_semantically_verified(SemanticallyVerifiedBlock::from(block), treestate) + #[cfg(feature = "tx_v6")] + let issued_assets = Some(block.issued_assets.clone()); + Self::from_semantically_verified( + SemanticallyVerifiedBlock::from(block), + treestate, + #[cfg(feature = "tx_v6")] + 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, + #[cfg(feature = "tx_v6")] issued_assets: Option, + ) -> Self { Self { block: block.block, hash: block.hash, @@ -319,6 +368,8 @@ impl FinalizedBlock { transaction_hashes: block.transaction_hashes, treestate, deferred_balance: block.deferred_balance, + #[cfg(feature = "tx_v6")] + issued_assets, } } } @@ -384,6 +435,7 @@ impl ContextuallyVerifiedBlock { pub fn with_block_and_spent_utxos( semantically_verified: SemanticallyVerifiedBlock, mut spent_outputs: HashMap, + #[cfg(feature = "tx_v6")] issued_assets: IssuedAssets, ) -> Result { let SemanticallyVerifiedBlock { block, @@ -411,6 +463,8 @@ impl ContextuallyVerifiedBlock { &utxos_from_ordered_utxos(spent_outputs), deferred_balance, )?, + #[cfg(feature = "tx_v6")] + issued_assets, }) } } @@ -427,6 +481,7 @@ impl CheckpointVerifiedBlock { block.deferred_balance = deferred_balance; block } + /// Creates a block that's ready to be committed to the finalized state, /// using a precalculated [`block::Hash`]. /// @@ -465,7 +520,7 @@ impl SemanticallyVerifiedBlock { impl From> for CheckpointVerifiedBlock { fn from(block: Arc) -> Self { - CheckpointVerifiedBlock(SemanticallyVerifiedBlock::from(block)) + Self(SemanticallyVerifiedBlock::from(block)) } } @@ -508,19 +563,6 @@ impl From for SemanticallyVerifiedBlock { } } -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, - } - } -} - impl From for SemanticallyVerifiedBlock { fn from(checkpoint_verified: CheckpointVerifiedBlock) -> Self { checkpoint_verified.0 @@ -1068,6 +1110,17 @@ pub enum ReadRequest { /// Returns [`ReadResponse::TipBlockSize(usize)`](ReadResponse::TipBlockSize) /// with the current best chain tip block size in bytes. TipBlockSize, + + #[cfg(feature = "tx_v6")] + /// Returns [`ReadResponse::AssetState`] with an [`AssetState`](zebra_chain::orchard_zsa::AssetState) + /// of the provided [`AssetBase`] if it exists for the best chain tip or finalized chain tip (depending + /// on the `include_non_finalized` flag). + AssetState { + /// The [`AssetBase`] to return the asset state for. + asset_base: AssetBase, + /// Whether to include the issued asset state changes in the non-finalized state. + include_non_finalized: bool, + }, } impl ReadRequest { @@ -1105,6 +1158,8 @@ impl ReadRequest { ReadRequest::CheckBlockProposalValidity(_) => "check_block_proposal_validity", #[cfg(feature = "getblocktemplate-rpcs")] ReadRequest::TipBlockSize => "tip_block_size", + #[cfg(feature = "tx_v6")] + ReadRequest::AssetState { .. } => "asset_state", } } diff --git a/zebra-state/src/response.rs b/zebra-state/src/response.rs index 77c252b0c75..1ddef4fa86c 100644 --- a/zebra-state/src/response.rs +++ b/zebra-state/src/response.rs @@ -13,6 +13,9 @@ use zebra_chain::{ value_balance::ValueBalance, }; +#[cfg(feature = "tx_v6")] +use zebra_chain::orchard_zsa::AssetState; + #[cfg(feature = "getblocktemplate-rpcs")] use zebra_chain::work::difficulty::CompactDifficulty; @@ -233,6 +236,10 @@ pub enum ReadResponse { #[cfg(feature = "getblocktemplate-rpcs")] /// Response to [`ReadRequest::TipBlockSize`] TipBlockSize(Option), + + #[cfg(feature = "tx_v6")] + /// Response to [`ReadRequest::AssetState`] + AssetState(Option), } /// A structure with the information needed from the state to build a `getblocktemplate` RPC response. @@ -322,6 +329,9 @@ impl TryFrom for Response { ReadResponse::ChainInfo(_) | ReadResponse::SolutionRate(_) | ReadResponse::TipBlockSize(_) => { Err("there is no corresponding Response for this ReadResponse") } + + #[cfg(feature = "tx_v6")] + ReadResponse::AssetState(_) => Err("there is no corresponding Response for this ReadResponse"), } } } diff --git a/zebra-state/src/service.rs b/zebra-state/src/service.rs index adc61f887ae..c4a593a160c 100644 --- a/zebra-state/src/service.rs +++ b/zebra-state/src/service.rs @@ -1947,6 +1947,30 @@ impl Service for ReadStateService { }) .wait_for_panics() } + + #[cfg(feature = "tx_v6")] + ReadRequest::AssetState { + asset_base, + include_non_finalized, + } => { + let state = self.clone(); + + tokio::task::spawn_blocking(move || { + span.in_scope(move || { + let best_chain = include_non_finalized + .then(|| state.latest_best_chain()) + .flatten(); + + let response = read::asset_state(best_chain, &state.db, &asset_base); + + // The work is done in the future. + timer.finish(module_path!(), line!(), "ReadRequest::AssetState"); + + Ok(ReadResponse::AssetState(response)) + }) + }) + .wait_for_panics() + } } } } diff --git a/zebra-state/src/service/check.rs b/zebra-state/src/service/check.rs index ced63bfea16..82e88005e5e 100644 --- a/zebra-state/src/service/check.rs +++ b/zebra-state/src/service/check.rs @@ -31,6 +31,9 @@ pub(crate) mod difficulty; pub(crate) mod nullifier; pub(crate) mod utxo; +#[cfg(feature = "tx_v6")] +pub(crate) mod issuance; + pub use utxo::transparent_coinbase_spend; #[cfg(test)] diff --git a/zebra-state/src/service/check/anchors.rs b/zebra-state/src/service/check/anchors.rs index b4a53c8f176..488d9361a76 100644 --- a/zebra-state/src/service/check/anchors.rs +++ b/zebra-state/src/service/check/anchors.rs @@ -88,9 +88,12 @@ fn sapling_orchard_anchors_refer_to_final_treestates( // > earlier block’s final Orchard treestate. // // - if let Some(shared_anchor) = transaction.orchard_shared_anchor() { + for (shared_anchor_index_in_tx, shared_anchor) in + transaction.orchard_shared_anchors().enumerate() + { tracing::debug!( ?shared_anchor, + ?shared_anchor_index_in_tx, ?tx_index_in_block, ?height, "observed orchard anchor", @@ -111,6 +114,7 @@ fn sapling_orchard_anchors_refer_to_final_treestates( tracing::debug!( ?shared_anchor, + ?shared_anchor_index_in_tx, ?tx_index_in_block, ?height, "validated orchard anchor", diff --git a/zebra-state/src/service/check/issuance.rs b/zebra-state/src/service/check/issuance.rs new file mode 100644 index 00000000000..472ffd61fd8 --- /dev/null +++ b/zebra-state/src/service/check/issuance.rs @@ -0,0 +1,72 @@ +//! Checks for issuance and burn validity. + +use std::{collections::HashMap, sync::Arc}; + +use zebra_chain::orchard_zsa::{AssetBase, AssetState, IssuedAssets, IssuedAssetsChange}; + +use crate::{service::read, SemanticallyVerifiedBlock, ValidateContextError, ZebraDb}; + +use super::Chain; + +pub fn valid_burns_and_issuance( + finalized_state: &ZebraDb, + parent_chain: &Arc, + semantically_verified: &SemanticallyVerifiedBlock, +) -> Result { + let mut issued_assets = HashMap::new(); + + // Burns need to be checked and asset state changes need to be applied per tranaction, in case + // the asset being burned was also issued in an earlier transaction in the same block. + for transaction in &semantically_verified.block.transactions { + let issued_assets_change = IssuedAssetsChange::from_transaction(transaction) + .ok_or(ValidateContextError::InvalidIssuance)?; + + // 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) + // The asset being burned should have been issued by a previous transaction, and + // any assets issued in previous transactions should be present in the issued assets map. + .ok_or(ValidateContextError::InvalidBurn)?; + + if asset_state.total_supply < burn.raw_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); + } + } + + // TODO: Remove the `issued_assets_change` field from `SemanticallyVerifiedBlock` and get the changes + // directly from transactions here and when writing blocks to disk. + 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)?; + + // TODO: Update `Burn` to `HashMap)` and return an error during deserialization if + // any asset base is burned twice in the same transaction + issued_assets.insert(asset_base, updated_asset_state); + } + } + + Ok(issued_assets.into()) +} + +fn asset_state( + finalized_state: &ZebraDb, + parent_chain: &Arc, + issued_assets: &HashMap, + asset_base: &AssetBase, +) -> Option { + issued_assets + .get(asset_base) + .copied() + .or_else(|| read::asset_state(Some(parent_chain), finalized_state, asset_base)) +} diff --git a/zebra-state/src/service/check/tests.rs b/zebra-state/src/service/check/tests.rs index 9608105766d..e82e9be681e 100644 --- a/zebra-state/src/service/check/tests.rs +++ b/zebra-state/src/service/check/tests.rs @@ -1,6 +1,7 @@ //! Tests for state contextual validation checks. mod anchors; +mod issuance; mod nullifier; mod utxo; mod vectors; 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..e4fbd3deb02 --- /dev/null +++ b/zebra-state/src/service/check/tests/issuance.rs @@ -0,0 +1,94 @@ +use std::sync::Arc; + +use zebra_chain::{ + block::{self, genesis::regtest_genesis_block, Block}, + orchard_zsa::IssuedAssets, + parameters::Network, + serialization::ZcashDeserialize, +}; + +use zebra_test::vectors::{OrchardWorkflowBlock, ORCHARD_ZSA_WORKFLOW_BLOCKS}; + +use crate::{ + check::{self, Chain}, + service::{finalized_state::FinalizedState, write::validate_and_commit_non_finalized}, + CheckpointVerifiedBlock, Config, NonFinalizedState, +}; + +fn valid_issuance_blocks() -> Vec> { + ORCHARD_ZSA_WORKFLOW_BLOCKS + .iter() + .map(|OrchardWorkflowBlock { bytes, .. }| { + Arc::new(Block::zcash_deserialize(&bytes[..]).expect("block should deserialize")) + }) + .collect() +} + +#[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); + + 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/check/tests/nullifier.rs b/zebra-state/src/service/check/tests/nullifier.rs index 8a8b17e4fd0..a6c15832d9b 100644 --- a/zebra-state/src/service/check/tests/nullifier.rs +++ b/zebra-state/src/service/check/tests/nullifier.rs @@ -1134,9 +1134,11 @@ fn transaction_v5_with_orchard_shielded_data( if let Some(ref mut orchard_shielded_data) = orchard_shielded_data { // make sure there are no other nullifiers, by replacing all the authorized_actions - orchard_shielded_data.actions = authorized_actions.try_into().expect( - "unexpected invalid orchard::ShieldedData: must have at least one AuthorizedAction", - ); + // FIXME: works for V5 or V6 with a single action group only + orchard_shielded_data.action_groups.first_mut().actions = + authorized_actions.try_into().expect( + "unexpected invalid orchard::ShieldedData: must have at least one AuthorizedAction", + ); // set value balance to 0 to pass the chain value pool checks let zero_amount = 0.try_into().expect("unexpected invalid zero amount"); 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..8c8fc3c30a2 100644 --- a/zebra-state/src/service/finalized_state/disk_format/shielded.rs +++ b/zebra-state/src/service/finalized_state/disk_format/shielded.rs @@ -13,6 +13,9 @@ use zebra_chain::{ subtree::{NoteCommitmentSubtreeData, NoteCommitmentSubtreeIndex}, }; +#[cfg(feature = "tx_v6")] +use zebra_chain::orchard_zsa::{AssetBase, AssetState}; + use crate::service::finalized_state::disk_format::{FromDisk, IntoDisk}; use super::block::HEIGHT_DISK_BYTES; @@ -207,3 +210,50 @@ impl FromDisk for NoteCommitmentSubtreeData { ) } } + +// TODO: Replace `.unwrap()`s with `.expect()`s + +#[cfg(feature = "tx_v6")] +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() + } +} + +#[cfg(feature = "tx_v6")] +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), + } + } +} + +#[cfg(feature = "tx_v6")] +impl IntoDisk for AssetBase { + type Bytes = [u8; 32]; + + fn as_bytes(&self) -> Self::Bytes { + self.to_bytes() + } +} + +#[cfg(feature = "tx_v6")] +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/arbitrary.rs b/zebra-state/src/service/finalized_state/zebra_db/arbitrary.rs index 39bf082d43d..25b55e8e57f 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/arbitrary.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/arbitrary.rs @@ -66,7 +66,7 @@ impl ZebraDb { } // Orchard - if let Some(shared_anchor) = transaction.orchard_shared_anchor() { + for shared_anchor in transaction.orchard_shared_anchors() { batch.zs_insert(&orchard_anchors, shared_anchor, ()); } } 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/shielded.rs b/zebra-state/src/service/finalized_state/zebra_db/shielded.rs index 4bba75b1891..abd2e70431c 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/shielded.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/shielded.rs @@ -19,13 +19,16 @@ use std::{ use zebra_chain::{ block::Height, - orchard, + orchard::{self}, parallel::tree::NoteCommitmentTrees, sapling, sprout, subtree::{NoteCommitmentSubtreeData, NoteCommitmentSubtreeIndex}, transaction::Transaction, }; +#[cfg(feature = "tx_v6")] +use zebra_chain::orchard_zsa::{AssetBase, AssetState, IssuedAssetsChange}; + use crate::{ request::{FinalizedBlock, Treestate}, service::finalized_state::{ @@ -36,11 +39,34 @@ use crate::{ BoxError, }; +#[cfg(feature = "tx_v6")] +use crate::service::finalized_state::TypedColumnFamily; + // Doc-only items #[allow(unused_imports)] use zebra_chain::subtree::NoteCommitmentSubtree; +#[cfg(feature = "tx_v6")] +/// 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"; + +#[cfg(feature = "tx_v6")] +/// 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 { + #[cfg(feature = "tx_v6")] + /// 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 +436,12 @@ impl ZebraDb { Some(subtree_data.with_index(index)) } + #[cfg(feature = "tx_v6")] + /// 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 +469,19 @@ 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)?; } + #[cfg(feature = "tx_v6")] + self.prepare_issued_assets_batch(zebra_db, finalized)?; + Ok(()) } @@ -480,6 +515,39 @@ impl DiskWriteBatch { Ok(()) } + #[cfg(feature = "tx_v6")] + /// 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, + finalized: &FinalizedBlock, + ) -> Result<(), BoxError> { + let mut batch = zebra_db.issued_assets_cf().with_batch_for_writing(self); + + let updated_issued_assets = + if let Some(updated_issued_assets) = finalized.issued_assets.as_ref() { + updated_issued_assets + } else { + &IssuedAssetsChange::from( + IssuedAssetsChange::from_transactions(&finalized.block.transactions) + .ok_or(BoxError::from("invalid issued assets changes"))?, + ) + .apply_with(|asset_base| zebra_db.issued_asset(&asset_base).unwrap_or_default()) + }; + + for (asset_base, updated_issued_asset_state) in updated_issued_assets.iter() { + 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..b0791fc8a62 100644 --- a/zebra-state/src/service/non_finalized_state.rs +++ b/zebra-state/src/service/non_finalized_state.rs @@ -325,6 +325,10 @@ impl NonFinalizedState { finalized_state, )?; + #[cfg(feature = "tx_v6")] + 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 +347,9 @@ 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. + #[cfg(feature = "tx_v6")] + 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 a002c301766..8071c199a5f 100644 --- a/zebra-state/src/service/non_finalized_state/chain.rs +++ b/zebra-state/src/service/non_finalized_state/chain.rs @@ -21,13 +21,18 @@ use zebra_chain::{ primitives::Groth16Proof, sapling, sprout, subtree::{NoteCommitmentSubtree, NoteCommitmentSubtreeData, NoteCommitmentSubtreeIndex}, - transaction::Transaction::*, - transaction::{self, Transaction}, + transaction::{ + self, + Transaction::{self, *}, + }, transparent, value_balance::ValueBalance, work::difficulty::PartialCumulativeWork, }; +#[cfg(feature = "tx_v6")] +use zebra_chain::orchard_zsa::{AssetBase, AssetState, IssuedAssets, IssuedAssetsChange}; + use crate::{ request::Treestate, service::check, ContextuallyVerifiedBlock, HashOrHeight, OutputLocation, TransactionLocation, ValidateContextError, @@ -174,6 +179,12 @@ pub struct ChainInner { pub(crate) orchard_subtrees: BTreeMap>, + #[cfg(feature = "tx_v6")] + /// 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 +248,8 @@ impl Chain { orchard_anchors_by_height: Default::default(), orchard_trees_by_height: Default::default(), orchard_subtrees: Default::default(), + #[cfg(feature = "tx_v6")] + issued_assets: Default::default(), sprout_nullifiers: Default::default(), sapling_nullifiers: Default::default(), orchard_nullifiers: Default::default(), @@ -937,6 +950,49 @@ impl Chain { } } + #[cfg(feature = "tx_v6")] + /// 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() + } + + #[cfg(feature = "tx_v6")] + /// 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 +1495,10 @@ impl Chain { self.add_history_tree(height, history_tree); + #[cfg(feature = "tx_v6")] + self.issued_assets + .extend(contextually_valid.issued_assets.clone()); + Ok(()) } @@ -1677,6 +1737,9 @@ impl UpdateWith for Chain { &contextually_valid.chain_value_pool_change, ); + #[cfg(feature = "tx_v6")] + let issued_assets = &contextually_valid.issued_assets; + // remove the blocks hash from `height_by_hash` assert!( self.height_by_hash.remove(&hash).is_some(), @@ -1696,21 +1759,22 @@ impl UpdateWith for Chain { for (transaction, transaction_hash) in block.transactions.iter().zip(transaction_hashes.iter()) { - let ( - inputs, - outputs, - joinsplit_data, - sapling_shielded_data_per_spend_anchor, - sapling_shielded_data_shared_anchor, - orchard_shielded_data, - ) = match transaction.deref() { + let transaction_data = match transaction.deref() { V4 { inputs, outputs, joinsplit_data, sapling_shielded_data, .. - } => (inputs, outputs, joinsplit_data, sapling_shielded_data, &None, &None), + } => ( + inputs, + outputs, + joinsplit_data, + sapling_shielded_data, + &None, + &None, + #[cfg(feature = "tx_v6")] + &None), V5 { inputs, outputs, @@ -1724,13 +1788,15 @@ impl UpdateWith for Chain { &None, sapling_shielded_data, orchard_shielded_data, + #[cfg(feature = "tx_v6")] + &None, ), #[cfg(feature = "tx_v6")] V6 { inputs, outputs, sapling_shielded_data, - orchard_shielded_data: _, + orchard_shielded_data, .. } => ( inputs, @@ -1738,14 +1804,35 @@ impl UpdateWith for Chain { &None, &None, sapling_shielded_data, - // FIXME: support V6 shielded data? - &None, //orchard_shielded_data, + &None, + orchard_shielded_data, ), V1 { .. } | V2 { .. } | V3 { .. } => unreachable!( "older transaction versions only exist in finalized blocks, because of the mandatory canopy checkpoint", ), }; + #[cfg(not(feature = "tx_v6"))] + let ( + inputs, + outputs, + joinsplit_data, + sapling_shielded_data_per_spend_anchor, + sapling_shielded_data_shared_anchor, + orchard_shielded_data_vanilla, + ) = transaction_data; + + #[cfg(feature = "tx_v6")] + let ( + inputs, + outputs, + joinsplit_data, + sapling_shielded_data_per_spend_anchor, + sapling_shielded_data_shared_anchor, + orchard_shielded_data_vanilla, + orchard_shielded_data_zsa, + ) = transaction_data; + // remove the utxos this produced self.revert_chain_with(&(outputs, transaction_hash, new_outputs), position); // reset the utxos this consumed @@ -1762,7 +1849,9 @@ impl UpdateWith for Chain { self.revert_chain_with(joinsplit_data, position); self.revert_chain_with(sapling_shielded_data_per_spend_anchor, position); self.revert_chain_with(sapling_shielded_data_shared_anchor, position); - self.revert_chain_with(orchard_shielded_data, position); + self.revert_chain_with(orchard_shielded_data_vanilla, position); + #[cfg(feature = "tx_v6")] + self.revert_chain_with(orchard_shielded_data_zsa, position); } // TODO: move these to the shielded UpdateWith.revert...()? @@ -1773,6 +1862,10 @@ impl UpdateWith for Chain { // TODO: move this to the history tree UpdateWith.revert...()? self.remove_history_tree(position, height); + #[cfg(feature = "tx_v6")] + // 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..f28689c9722 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,8 @@ fn push_genesis_chain() -> Result<()> { ContextuallyVerifiedBlock::with_block_and_spent_utxos( block, only_chain.unspent_utxos(), + #[cfg(feature = "tx_v6")] + Default::default(), ) .map_err(|e| (e, chain_values.clone())) .expect("invalid block value pool change"); @@ -148,6 +150,8 @@ fn forked_equals_pushed_genesis() -> Result<()> { let block = ContextuallyVerifiedBlock::with_block_and_spent_utxos( block, partial_chain.unspent_utxos(), + #[cfg(feature = "tx_v6")] + Default::default() )?; partial_chain = partial_chain .push(block) @@ -166,8 +170,12 @@ 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())?; + let block = ContextuallyVerifiedBlock::with_block_and_spent_utxos( + block, + full_chain.unspent_utxos(), + #[cfg(feature = "tx_v6")] + 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 +218,9 @@ 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(), + #[cfg(feature = "tx_v6")] + Default::default())?; forked = forked.push(block).expect("forked chain push is valid"); } diff --git a/zebra-state/src/service/read.rs b/zebra-state/src/service/read.rs index 0188ca1bf5e..1450a390348 100644 --- a/zebra-state/src/service/read.rs +++ b/zebra-state/src/service/read.rs @@ -38,6 +38,10 @@ pub use find::{ find_chain_headers, hash_by_height, height_by_hash, next_median_time_past, non_finalized_state_contains_block_hash, tip, tip_height, tip_with_value_balance, }; + +#[cfg(feature = "tx_v6")] +pub use find::asset_state; + pub use tree::{orchard_subtrees, orchard_tree, sapling_subtrees, sapling_tree}; #[cfg(any(test, feature = "proptest-impl"))] diff --git a/zebra-state/src/service/read/find.rs b/zebra-state/src/service/read/find.rs index e9d557dbfb2..e87c396199f 100644 --- a/zebra-state/src/service/read/find.rs +++ b/zebra-state/src/service/read/find.rs @@ -25,6 +25,9 @@ use zebra_chain::{ value_balance::ValueBalance, }; +#[cfg(feature = "tx_v6")] +use zebra_chain::orchard_zsa::{AssetBase, AssetState}; + use crate::{ constants, service::{ @@ -679,3 +682,14 @@ pub(crate) fn calculate_median_time_past(relevant_chain: Vec>) -> Dat DateTime32::try_from(median_time_past).expect("valid blocks have in-range times") } + +#[cfg(feature = "tx_v6")] +/// Return the [`AssetState`] for the provided [`AssetBase`], if it exists in the provided chain. +pub fn asset_state(chain: Option, db: &ZebraDb, asset_base: &AssetBase) -> Option +where + C: AsRef, +{ + chain + .and_then(|chain| chain.as_ref().issued_asset(asset_base)) + .or_else(|| db.issued_asset(asset_base)) +} diff --git a/zebra-test/src/vectors/orchard_zsa_workflow_blocks.rs b/zebra-test/src/vectors/orchard_zsa_workflow_blocks.rs index ee597f38e67..aaa47b67792 100644 --- a/zebra-test/src/vectors/orchard_zsa_workflow_blocks.rs +++ b/zebra-test/src/vectors/orchard_zsa_workflow_blocks.rs @@ -1,23 +1,55 @@ -//! OrchardZSA test vectors +//! OrchardZSA workflow test blocks + +#![allow(missing_docs)] use hex::FromHex; use lazy_static::lazy_static; +/// Represents a serialized block and its validity status. +pub struct OrchardWorkflowBlock { + /// Serialized byte data of the block. + pub bytes: Vec, + /// Indicates whether the block is valid. + pub is_valid: bool, +} + +fn decode_bytes(hex: &str) -> Vec { + >::from_hex((hex).trim()).expect("Block bytes are in valid hex representation") +} + lazy_static! { -/// Test blocks for a Zcash Shielded Assets (ZSA) workflow. -/// The sequence demonstrates issuing, transferring and burning a custom -/// asset, then finalising the issuance and attempting an extra issue. -pub static ref ORCHARD_ZSA_WORKFLOW_BLOCKS: [Vec; 5] = [ + /// Test blocks for a Zcash Shielded Assets (ZSA) workflow. + /// The sequence demonstrates issuing, transferring and burning a custom + /// asset, then finalising the issuance and attempting an extra issue. + pub static ref ORCHARD_ZSA_WORKFLOW_BLOCKS: Vec = vec![ // Issue: 1000 - include_str!("orchard-zsa-workflow-block-1.txt").trim(), + OrchardWorkflowBlock { + bytes: decode_bytes(include_str!("orchard-zsa-workflow-block-1.txt")), + is_valid: true + }, + // Transfer - include_str!("orchard-zsa-workflow-block-2.txt").trim(), + OrchardWorkflowBlock { + bytes: decode_bytes(include_str!("orchard-zsa-workflow-block-2.txt")), + is_valid: true + }, + // Burn: 7, Burn: 2 - include_str!("orchard-zsa-workflow-block-3.txt").trim(), + OrchardWorkflowBlock { + bytes: decode_bytes(include_str!("orchard-zsa-workflow-block-3.txt")), + is_valid: true + }, + // Issue: finalize - include_str!("orchard-zsa-workflow-block-4.txt").trim(), + OrchardWorkflowBlock { + bytes: decode_bytes(include_str!("orchard-zsa-workflow-block-4.txt")), + is_valid: true + }, + // Try to issue: 2000 - include_str!("orchard-zsa-workflow-block-5.txt").trim(), - ] - .map(|hex| >::from_hex(hex).expect("Block bytes are in valid hex representation")); + OrchardWorkflowBlock { + bytes: decode_bytes(include_str!("orchard-zsa-workflow-block-5.txt")), + is_valid: false + }, + ]; } diff --git a/zebrad/Cargo.toml b/zebrad/Cargo.toml index 0691a2367be..0137892e205 100644 --- a/zebrad/Cargo.toml +++ b/zebrad/Cargo.toml @@ -163,6 +163,13 @@ tx_v6 = [ "zebra-chain/tx_v6" ] +# Support for ZSA asset swaps +zsa-swap = [ + "zebra-consensus/zsa-swap", + "zebra-state/zsa-swap", + "zebra-chain/zsa-swap" +] + [dependencies] zebra-chain = { path = "../zebra-chain", version = "1.0.0-beta.41" } zebra-consensus = { path = "../zebra-consensus", version = "1.0.0-beta.41" } diff --git a/zebrad/build.rs b/zebrad/build.rs index efac0a69774..21fd4049597 100644 --- a/zebrad/build.rs +++ b/zebrad/build.rs @@ -6,6 +6,7 @@ //! When compiling the `lightwalletd` gRPC tests, also builds a gRPC client //! Rust API for `lightwalletd`. +use std::process::Command; use vergen::EmitBuilder; /// Returns a new `vergen` builder, configured for everything except for `git` env vars. @@ -18,6 +19,18 @@ fn base_vergen_builder() -> EmitBuilder { vergen } +/// Run a git command and return the output, or a fallback value if it fails +fn run_git_command(args: &[&str], fallback: &str) -> String { + Command::new("git") + .args(args) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| fallback.to_owned()) +} + /// Process entry point for `zebrad`'s build script #[allow(clippy::print_stderr)] fn main() { @@ -52,4 +65,13 @@ fn main() { &["tests/common/lightwalletd/proto"], ) .expect("Failed to generate lightwalletd gRPC files"); + + // Add custom git tag and commit information + // Use environment variables if available (from CI/CD), otherwise try git commands (Can be problematic as the docker image shouldn't contain the .git folder) + let git_commit = std::env::var("GIT_COMMIT_FULL") + .unwrap_or_else(|_| run_git_command(&["rev-parse", "HEAD"], "none")); + let git_tag = std::env::var("GIT_TAG") + .unwrap_or_else(|_| run_git_command(&["describe", "--exact-match", "--tags"], "none")); + println!("cargo:rustc-env=GIT_TAG={}", git_tag); + println!("cargo:rustc-env=GIT_COMMIT_FULL={}", git_commit); } diff --git a/zebrad/src/application.rs b/zebrad/src/application.rs index b26734f943a..5794e7dc556 100644 --- a/zebrad/src/application.rs +++ b/zebrad/src/application.rs @@ -295,6 +295,8 @@ impl Application for ZebradApp { let git_metadata: &[(_, Option<_>)] = &[ ("branch", option_env!("VERGEN_GIT_BRANCH")), ("git commit", Self::git_commit()), + ("git tag", option_env!("GIT_TAG")), + ("git commit full", option_env!("GIT_COMMIT_FULL")), ( "commit timestamp", option_env!("VERGEN_GIT_COMMIT_TIMESTAMP"), diff --git a/zebrad/src/components/mempool/storage/tests/prop.rs b/zebrad/src/components/mempool/storage/tests/prop.rs index ef0da69d2d1..31cbf786178 100644 --- a/zebrad/src/components/mempool/storage/tests/prop.rs +++ b/zebrad/src/components/mempool/storage/tests/prop.rs @@ -773,19 +773,18 @@ impl SpendConflictTestInput { maybe_shielded_data: &mut Option>, conflicts: &HashSet, ) { - if let Some(shielded_data) = maybe_shielded_data.take() { - let updated_actions: Vec<_> = shielded_data - .actions - .into_vec() - .into_iter() - .filter(|action| !conflicts.contains(&action.action.nullifier)) - .collect(); - - if let Ok(actions) = AtLeastOne::try_from(updated_actions) { - *maybe_shielded_data = Some(orchard::ShieldedData { - actions, - ..shielded_data - }); + if let Some(shielded_data) = maybe_shielded_data { + for action_group in shielded_data.action_groups.iter_mut() { + let updated_actions: Vec<_> = action_group + .actions + .clone() + .into_iter() + .filter(|action| !conflicts.contains(&action.action.nullifier)) + .collect(); + + if let Ok(actions) = AtLeastOne::try_from(updated_actions) { + action_group.actions = actions; + } } } } @@ -1013,8 +1012,19 @@ impl OrchardSpendConflict { /// The transaction will then conflict with any other transaction with the same new nullifier. pub fn apply_to(self, orchard_shielded_data: &mut Option>) { if let Some(shielded_data) = orchard_shielded_data.as_mut() { - shielded_data.actions.first_mut().action.nullifier = - self.new_shielded_data.actions.first().action.nullifier; + // FIXME: works for V5 or V6 with a single action group only + shielded_data + .action_groups + .first_mut() + .actions + .first_mut() + .action + .nullifier = self + .new_shielded_data + .actions() + .next() + .expect("at least one action") + .nullifier; } else { *orchard_shielded_data = Some(self.new_shielded_data.0); }