diff --git a/Cargo.lock b/Cargo.lock index ad292ce9d1..bc6c4590cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3798,7 +3798,7 @@ dependencies = [ [[package]] name = "sapling-crypto" version = "0.3.0" -source = "git+https://github.com/zcash/sapling-crypto.git?rev=f228f52542749ea89f4a7cffbc0682ed9ea4b8d1#f228f52542749ea89f4a7cffbc0682ed9ea4b8d1" +source = "git+https://github.com/zcash/sapling-crypto.git?rev=29cff9683cdf2f0c522ff3224081dfb4fbc80248#29cff9683cdf2f0c522ff3224081dfb4fbc80248" dependencies = [ "aes", "bellman", @@ -5638,6 +5638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" dependencies = [ "getrandom", + "serde", ] [[package]] @@ -6140,6 +6141,7 @@ dependencies = [ "bs58", "f4jumble", "proptest", + "serde", "zcash_encoding", "zcash_protocol", ] @@ -6175,7 +6177,9 @@ dependencies = [ "nonempty", "orchard", "pasta_curves", + "pczt", "percent-encoding", + "postcard", "proptest", "prost", "rand", @@ -6240,6 +6244,7 @@ dependencies = [ "schemerz", "schemerz-rusqlite", "secrecy", + "serde", "shardtree", "static_assertions", "subtle", @@ -6325,9 +6330,9 @@ dependencies = [ [[package]] name = "zcash_note_encryption" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b4580cd6cee12e44421dac43169be8d23791650816bdb34e6ddfa70ac89c1c5" +checksum = "77efec759c3798b6e4d829fcc762070d9b229b0f13338c40bf993b7b609c2272" dependencies = [ "chacha20", "chacha20poly1305", diff --git a/Cargo.toml b/Cargo.toml index 6f0fc2932a..43f95c92c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,10 +40,12 @@ zcash_keys = { version = "0.5", path = "zcash_keys" } zcash_protocol = { version = "0.4.1", path = "components/zcash_protocol" } zip321 = { version = "0.2", path = "components/zip321" } -zcash_note_encryption = "0.4" +zcash_note_encryption = "0.4.1" zcash_primitives = { version = "0.20", path = "zcash_primitives", default-features = false } zcash_proofs = { version = "0.20", path = "zcash_proofs", default-features = false } +pczt = { version = "0.0", path = "pczt" } + # Shielded protocols bellman = { version = "0.14", default-features = false, features = ["groth16"] } ff = "0.13" @@ -95,6 +97,7 @@ bs58 = { version = "0.5", features = ["check"] } byteorder = "1" hex = "0.4" percent-encoding = "2.1.0" +postcard = { version = "1", features = ["alloc"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -188,4 +191,4 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(zcash_unstable, values("zf [patch.crates-io] orchard = { git = "https://github.com/zcash/orchard.git", rev = "bcd08e1d23e70c42a338f3e3f79d6f4c0c219805" } -sapling-crypto = { git = "https://github.com/zcash/sapling-crypto.git", rev = "f228f52542749ea89f4a7cffbc0682ed9ea4b8d1" } +sapling-crypto = { git = "https://github.com/zcash/sapling-crypto.git", rev = "29cff9683cdf2f0c522ff3224081dfb4fbc80248" } diff --git a/components/zcash_address/CHANGELOG.md b/components/zcash_address/CHANGELOG.md index 7eeb56b994..885f056ad9 100644 --- a/components/zcash_address/CHANGELOG.md +++ b/components/zcash_address/CHANGELOG.md @@ -6,6 +6,9 @@ and this library adheres to Rust's notion of [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `impl serde::{Serialize, Deserialize} for zcash_address::ZcashAddress` behind + the `serde` feature flag. ## [0.6.0] - 2024-10-02 ### Changed diff --git a/components/zcash_address/Cargo.toml b/components/zcash_address/Cargo.toml index f2201d2612..128e47309f 100644 --- a/components/zcash_address/Cargo.toml +++ b/components/zcash_address/Cargo.toml @@ -25,6 +25,7 @@ f4jumble = { version = "0.1", path = "../f4jumble" } zcash_protocol.workspace = true zcash_encoding.workspace = true proptest = { workspace = true, optional = true } +serde = { workspace = true, optional = true } [dev-dependencies] assert_matches.workspace = true @@ -32,6 +33,7 @@ proptest.workspace = true [features] test-dependencies = ["dep:proptest"] +serde = ["dep:serde"] [lib] bench = false diff --git a/components/zcash_address/src/lib.rs b/components/zcash_address/src/lib.rs index 32a3c05f4d..e33a991629 100644 --- a/components/zcash_address/src/lib.rs +++ b/components/zcash_address/src/lib.rs @@ -152,6 +152,49 @@ pub struct ZcashAddress { kind: AddressKind, } +#[cfg(feature = "serde")] +impl serde::Serialize for ZcashAddress { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.encode()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for ZcashAddress { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use std::fmt; + struct AddrVisitor; + + impl<'de> serde::de::Visitor<'de> for AddrVisitor { + type Value = ZcashAddress; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid Zcash address string") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + ZcashAddress::try_from_encoded(value).map_err(|_| { + serde::de::Error::invalid_value( + serde::de::Unexpected::Str(value), + &"a valid Zcash address string", + ) + }) + } + } + + deserializer.deserialize_str(AddrVisitor) + } +} + /// Known kinds of Zcash addresses. #[derive(Clone, Debug, PartialEq, Eq, Hash)] enum AddressKind { diff --git a/pczt/src/roles/creator/mod.rs b/pczt/src/roles/creator/mod.rs index 35e131dc76..7871a91f75 100644 --- a/pczt/src/roles/creator/mod.rs +++ b/pczt/src/roles/creator/mod.rs @@ -113,6 +113,8 @@ impl Creator { | zcash_primitives::transaction::TxVersion::Overwinter => None, zcash_primitives::transaction::TxVersion::Sapling => Some(SAPLING_TX_VERSION), zcash_primitives::transaction::TxVersion::Zip225 => Some(V5_TX_VERSION), + #[cfg(zcash_unstable = "zfuture")] + zcash_primitives::transaction::TxVersion::ZFuture => None, }?; // Spends and outputs not modifiable. diff --git a/pczt/src/roles/signer/mod.rs b/pczt/src/roles/signer/mod.rs index bdb74db9bd..61e041b6ba 100644 --- a/pczt/src/roles/signer/mod.rs +++ b/pczt/src/roles/signer/mod.rs @@ -307,6 +307,8 @@ impl Authorization for EffectsOnly { type TransparentAuth = transparent::EffectsOnly; type SaplingAuth = sapling::bundle::EffectsOnly; type OrchardAuth = orchard::bundle::EffectsOnly; + #[cfg(zcash_unstable = "zfuture")] + type TzeAuth = std::convert::Infallible; } /// Errors that can occur while creating signatures for a PCZT. diff --git a/pczt/src/roles/tx_extractor/mod.rs b/pczt/src/roles/tx_extractor/mod.rs index 06d6fdf95a..9ef6c12db6 100644 --- a/pczt/src/roles/tx_extractor/mod.rs +++ b/pczt/src/roles/tx_extractor/mod.rs @@ -126,6 +126,8 @@ impl<'a> TransactionExtractor<'a> { }) .transpose() }, + #[cfg(zcash_unstable = "zfuture")] + |_| unimplemented!("PCZT support for TZEs is not implemented."), )?; let tx = tx_data.freeze().expect("v5 tx can't fail here"); @@ -152,6 +154,8 @@ impl Authorization for Unbound { type TransparentAuth = zcash_primitives::transaction::components::transparent::pczt::Unbound; type SaplingAuth = ::sapling::pczt::Unbound; type OrchardAuth = ::orchard::pczt::Unbound; + #[cfg(zcash_unstable = "zfuture")] + type TzeAuth = std::convert::Infallible; } /// Errors that can occur while extracting a transaction from a PCZT. diff --git a/pczt/tests/end_to_end.rs b/pczt/tests/end_to_end.rs index e2e59b30ed..69da339eff 100644 --- a/pczt/tests/end_to_end.rs +++ b/pczt/tests/end_to_end.rs @@ -5,6 +5,7 @@ use pczt::{ roles::{ combiner::Combiner, creator::Creator, io_finalizer::IoFinalizer, prover::Prover, signer::Signer, spend_finalizer::SpendFinalizer, tx_extractor::TransactionExtractor, + updater::Updater, }, Pczt, }; @@ -49,6 +50,8 @@ fn transparent_to_orchard() { let transparent_sk = transparent_account_sk .derive_external_secret_key(address_index) .unwrap(); + let secp = secp256k1::Secp256k1::signing_only(); + let transparent_pubkey = transparent_sk.public_key(&secp); // Create an Orchard account to receive funds. let orchard_sk = orchard::keys::SpendingKey::from_bytes([0; 32]).unwrap(); @@ -73,7 +76,7 @@ fn transparent_to_orchard() { }, ); builder - .add_transparent_input(transparent_sk, utxo, coin) + .add_transparent_input(transparent_pubkey, utxo, coin) .unwrap(); builder .add_orchard_output::( @@ -157,7 +160,7 @@ fn sapling_to_orchard() { .add_output(None, sapling_recipient, value, None) .unwrap(); let (bundle, meta) = sapling_builder - .build::(&mut rng) + .build::(&[], &mut rng) .unwrap() .unwrap(); let output = bundle @@ -201,7 +204,7 @@ fn sapling_to_orchard() { }, ); builder - .add_sapling_spend::(&sapling_extsk, note, merkle_path) + .add_sapling_spend::(sapling_dfvk.fvk().clone(), note, merkle_path) .unwrap(); builder .add_orchard_output::( @@ -235,6 +238,17 @@ fn sapling_to_orchard() { let pczt = IoFinalizer::new(pczt).finalize_io().unwrap(); check_round_trip(&pczt); + // Update the Sapling bundle with its proof generation key. + let index = sapling_meta.spend_index(0).unwrap(); + let pczt = Updater::new(pczt) + .update_sapling_with(|mut updater| { + updater.update_spend_with(index, |mut spend_updater| { + spend_updater.set_proof_generation_key(sapling_extsk.expsk.proof_generation_key()) + }) + }) + .unwrap() + .finish(); + // To test the Combiner, we will create the Sapling proofs, Sapling signatures, and // Orchard proof "in parallel". @@ -258,7 +272,6 @@ fn sapling_to_orchard() { let pczt = Pczt::parse(&pczt.serialize()).unwrap(); // Apply signatures. - let index = sapling_meta.spend_index(0).unwrap(); let mut signer = Signer::new(pczt).unwrap(); signer .sign_sapling(index, &sapling_extsk.expsk.ask) @@ -350,7 +363,7 @@ fn orchard_to_orchard() { }, ); builder - .add_orchard_spend::(&orchard_sk, note, merkle_path) + .add_orchard_spend::(orchard_fvk.clone(), note, merkle_path) .unwrap(); builder .add_orchard_output::( diff --git a/supply-chain/audits.toml b/supply-chain/audits.toml index 4187397c7d..927f8c47f7 100644 --- a/supply-chain/audits.toml +++ b/supply-chain/audits.toml @@ -618,6 +618,12 @@ who = "Kris Nuttycombe " criteria = "safe-to-deploy" delta = "0.2.0 -> 0.3.0" +[[audits.zcash_note_encryption]] +who = "Kris Nuttycombe " +criteria = "safe-to-deploy" +version = "0.4.1" +notes = "Additive-only change that exposes the ability to decrypt by pk_d and esk. No functional changes." + [[audits.zcash_primitives]] who = "Kris Nuttycombe " criteria = "safe-to-deploy" diff --git a/supply-chain/imports.lock b/supply-chain/imports.lock index 61008c2d12..27e89ed684 100644 --- a/supply-chain/imports.lock +++ b/supply-chain/imports.lock @@ -62,20 +62,6 @@ user-id = 169181 user-login = "nuttycom" user-name = "Kris Nuttycombe" -[[publisher.orchard]] -version = "0.10.0" -when = "2024-10-02" -user-id = 169181 -user-login = "nuttycom" -user-name = "Kris Nuttycombe" - -[[publisher.sapling-crypto]] -version = "0.3.0" -when = "2024-10-02" -user-id = 169181 -user-login = "nuttycom" -user-name = "Kris Nuttycombe" - [[publisher.schemerz]] version = "0.2.0" when = "2024-10-16" diff --git a/zcash_client_backend/CHANGELOG.md b/zcash_client_backend/CHANGELOG.md index 020efc208d..b2afb2d8f8 100644 --- a/zcash_client_backend/CHANGELOG.md +++ b/zcash_client_backend/CHANGELOG.md @@ -8,7 +8,12 @@ and this library adheres to Rust's notion of ## [Unreleased] ### Added -- `zcash_client_backend::data_api::AccountSource::key_derivation` +- `zcash_client_backend::data_api` + - `AccountSource::key_derivation` + - `error::PcztError` + - `wallet::ExtractErrT` + - `wallet::create_pczt_from_proposal` + - `wallet::extract_and_store_transaction_from_pczt` ### Changed - `zcash_client_backend::data_api::AccountBalance`: Refactored to use `Balance` @@ -29,6 +34,10 @@ and this library adheres to Rust's notion of - The `request` argument to `WalletRead::get_next_available_address` is now optional. - `zcash_client_backend::data_api::Account` has an additional `name` method that returns the human-readable name of the account, if any. +- `zcash_client_backend::data_api::error::Error` has new variants: + - `AccountIdNotRecognized` + - `AccountCannotSpend` + - `Pczt` ### Deprecated - `AccountBalance::unshielded`. Instead use `unshielded_balance` which diff --git a/zcash_client_backend/Cargo.toml b/zcash_client_backend/Cargo.toml index 144faef43f..ceb7defd4f 100644 --- a/zcash_client_backend/Cargo.toml +++ b/zcash_client_backend/Cargo.toml @@ -33,6 +33,7 @@ zcash_primitives.workspace = true zcash_protocol.workspace = true zip32.workspace = true zip321.workspace = true +pczt = { workspace = true, optional = true } # Dependencies exposed in a public API: # (Breaking upgrades to these require a breaking upgrade to this crate.) @@ -47,6 +48,7 @@ rand_core.workspace = true base64.workspace = true bech32.workspace = true bs58.workspace = true +postcard = { workspace = true, optional = true } # - Errors bip32 = { workspace = true, optional = true } @@ -170,6 +172,22 @@ transparent-inputs = [ ## Enables receiving and spending Orchard funds. orchard = ["dep:orchard", "dep:pasta_curves", "zcash_keys/orchard"] +## Enables creating partially-constructed transactions for use in hardware wallet and multisig scenarios. +pczt = [ + "orchard", + "transparent-inputs", + "pczt/zcp-builder", + "pczt/io-finalizer", + "pczt/prover", + "pczt/signer", + "pczt/spend-finalizer", + "pczt/tx-extractor", + "pczt/zcp-builder", + "dep:postcard", + "dep:serde", + "zcash_address/serde", +] + ## Exposes a wallet synchronization function that implements the necessary state machine. sync = [ "lightwalletd-tonic", diff --git a/zcash_client_backend/src/data_api/error.rs b/zcash_client_backend/src/data_api/error.rs index 443b220ab2..e5a26a9a41 100644 --- a/zcash_client_backend/src/data_api/error.rs +++ b/zcash_client_backend/src/data_api/error.rs @@ -47,9 +47,16 @@ pub enum Error), - /// A proposed transaction cannot be built because it requires spending an input - /// for which no spending key is available. - /// - /// The argument is the address of the note or UTXO being spent. - NoSpendingKey(String), + /// A proposed transaction cannot be built because it requires spending an input of + /// a type for which a key required to construct the transaction is not available. + KeyNotAvailable(PoolType), /// A note being spent does not correspond to either the internal or external /// full viewing key for an account. @@ -101,6 +106,39 @@ pub enum Error fmt::Display for Error @@ -147,6 +185,18 @@ where "Wallet does not contain an account corresponding to the provided spending key" ) } + Error::AccountCannotSpend => { + write!( + f, + "The given account cannot be used for spending, because it is unable to maintain an accurate balance.", + ) + } + Error::AccountIdNotRecognized => { + write!( + f, + "Wallet does not contain an account corresponding to the provided ID" + ) + } Error::BalanceError(e) => write!( f, "The value lies outside the valid range of Zcash amounts: {:?}.", @@ -170,7 +220,7 @@ where acc }) ), - Error::NoSpendingKey(addr) => write!(f, "No spending key available for address: {}", addr), + Error::KeyNotAvailable(pool) => write!(f, "A key required for transaction construction was not available for pool type {}", pool), Error::NoteMismatch(n) => write!(f, "A note being spent ({:?}) does not correspond to either the internal or external full viewing key for the provided spending key.", n), Error::Address(e) => { @@ -184,6 +234,43 @@ where Error::PaysEphemeralTransparentAddress(addr) => { write!(f, "The wallet tried to pay to an ephemeral transparent address as a normal output: {}", addr) } + #[cfg(feature = "pczt")] + Error::Pczt(e) => write!(f, "PCZT error: {e}"), + } + } +} + +#[cfg(feature = "pczt")] +impl fmt::Display for PcztError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + PcztError::Build => { + write!( + f, + "Failed to generate the PCZT prior to proving or signing." + ) + } + PcztError::IoFinalization(e) => { + write!(f, "Failed to finalize IO: {:?}.", e) + } + PcztError::UpdateOrchard(e) => { + write!(f, "Failed to updating Orchard PCZT data: {:?}.", e) + } + PcztError::UpdateSapling(e) => { + write!(f, "Failed to updating Sapling PCZT data: {:?}.", e) + } + PcztError::UpdateTransparent(e) => { + write!(f, "Failed to updating transparent PCZT data: {:?}.", e) + } + PcztError::SpendFinalization(e) => { + write!(f, "Failed to finalize the PCZT spends: {:?}.", e) + } + PcztError::Extraction(e) => { + write!(f, "Failed to extract the final transaction: {:?}.", e) + } + PcztError::Invalid(e) => { + write!(f, "PCZT parsing resulted in an invalid condition: {}.", e) + } } } } @@ -204,11 +291,16 @@ where Error::NoteSelection(e) => Some(e), Error::Proposal(e) => Some(e), Error::Builder(e) => Some(e), + #[cfg(feature = "pczt")] + Error::Pczt(e) => Some(e), _ => None, } } } +#[cfg(feature = "pczt")] +impl error::Error for PcztError {} + impl From> for Error { fn from(e: builder::Error) -> Self { Error::Builder(e) @@ -272,3 +364,64 @@ impl From> for Error From for Error { + fn from(e: PcztError) -> Self { + Error::Pczt(e) + } +} + +#[cfg(feature = "pczt")] +impl From + for Error +{ + fn from(e: pczt::roles::io_finalizer::Error) -> Self { + Error::Pczt(PcztError::IoFinalization(e)) + } +} + +#[cfg(feature = "pczt")] +impl From + for Error +{ + fn from(e: pczt::roles::updater::OrchardError) -> Self { + Error::Pczt(PcztError::UpdateOrchard(e)) + } +} + +#[cfg(feature = "pczt")] +impl From + for Error +{ + fn from(e: pczt::roles::updater::SaplingError) -> Self { + Error::Pczt(PcztError::UpdateSapling(e)) + } +} + +#[cfg(feature = "pczt")] +impl From + for Error +{ + fn from(e: pczt::roles::updater::TransparentError) -> Self { + Error::Pczt(PcztError::UpdateTransparent(e)) + } +} + +#[cfg(feature = "pczt")] +impl From + for Error +{ + fn from(e: pczt::roles::spend_finalizer::Error) -> Self { + Error::Pczt(PcztError::SpendFinalization(e)) + } +} + +#[cfg(feature = "pczt")] +impl From + for Error +{ + fn from(e: pczt::roles::tx_extractor::Error) -> Self { + Error::Pczt(PcztError::Extraction(e)) + } +} diff --git a/zcash_client_backend/src/data_api/testing.rs b/zcash_client_backend/src/data_api/testing.rs index d80a9d4fb6..4eb469994f 100644 --- a/zcash_client_backend/src/data_api/testing.rs +++ b/zcash_client_backend/src/data_api/testing.rs @@ -1092,6 +1092,64 @@ where ) } + /// Invokes [`create_pczt_from_proposal`] with the given arguments. + /// + /// [`create_pczt_from_proposal`]: super::wallet::create_pczt_from_proposal + #[cfg(feature = "pczt")] + #[allow(clippy::type_complexity)] + pub fn create_pczt_from_proposal( + &mut self, + spend_from_account: ::AccountId, + ovk_policy: OvkPolicy, + proposal: &Proposal::NoteRef>, + ) -> Result< + pczt::Pczt, + super::wallet::CreateErrT, + > + where + ::AccountId: serde::Serialize, + FeeRuleT: FeeRule, + { + use super::wallet::create_pczt_from_proposal; + + let network = self.network().clone(); + + create_pczt_from_proposal( + self.wallet_mut(), + &network, + spend_from_account, + ovk_policy, + proposal, + ) + } + + /// Invokes [`extract_and_store_transaction_from_pczt`] with the given arguments. + /// + /// [`extract_and_store_transaction_from_pczt`]: super::wallet::extract_and_store_transaction_from_pczt + #[cfg(feature = "pczt")] + #[allow(clippy::type_complexity)] + pub fn extract_and_store_transaction_from_pczt( + &mut self, + pczt: pczt::Pczt, + ) -> Result> + where + ::AccountId: serde::de::DeserializeOwned, + { + use super::wallet::extract_and_store_transaction_from_pczt; + + let prover = LocalTxProver::bundled(); + let (spend_vk, output_vk) = prover.verifying_keys(); + let orchard_vk = ::orchard::circuit::VerifyingKey::build(); + + extract_and_store_transaction_from_pczt( + self.wallet_mut(), + pczt, + &spend_vk, + &output_vk, + &orchard_vk, + ) + } + /// Invokes [`shield_transparent_funds`] with the given arguments. /// /// [`shield_transparent_funds`]: crate::data_api::wallet::shield_transparent_funds diff --git a/zcash_client_backend/src/data_api/testing/orchard.rs b/zcash_client_backend/src/data_api/testing/orchard.rs index d076a6d7e9..d07f14ba4e 100644 --- a/zcash_client_backend/src/data_api/testing/orchard.rs +++ b/zcash_client_backend/src/data_api/testing/orchard.rs @@ -170,4 +170,39 @@ impl ShieldedPoolTester for OrchardPoolTester { fn received_note_count(summary: &ScanSummary) -> usize { summary.received_orchard_note_count() } + + #[cfg(feature = "pczt")] + fn add_proof_generation_keys( + pczt: pczt::Pczt, + _: &UnifiedSpendingKey, + ) -> Result { + // No-op; Orchard doesn't have proof generation keys. + Ok(pczt) + } + + #[cfg(feature = "pczt")] + fn apply_signatures_to_pczt( + signer: &mut pczt::roles::signer::Signer, + usk: &UnifiedSpendingKey, + ) -> Result<(), pczt::roles::signer::Error> { + let sk = Self::usk_to_sk(usk); + let ask = orchard::keys::SpendAuthorizingKey::from(sk); + + // Figuring out which one is for us is hard. Let's just try signing all of them! + for index in 0.. { + match signer.sign_orchard(index, &ask) { + // Loop termination. + Err(pczt::roles::signer::Error::InvalidIndex) => break, + // Ignore any errors due to using the wrong key. + Ok(()) + | Err(pczt::roles::signer::Error::OrchardSign( + orchard::pczt::SignerError::WrongSpendAuthorizingKey, + )) => Ok(()), + // Raise any unexpected errors. + Err(e) => Err(e), + }?; + } + + Ok(()) + } } diff --git a/zcash_client_backend/src/data_api/testing/pool.rs b/zcash_client_backend/src/data_api/testing/pool.rs index ba1ae57726..0cacac946b 100644 --- a/zcash_client_backend/src/data_api/testing/pool.rs +++ b/zcash_client_backend/src/data_api/testing/pool.rs @@ -84,6 +84,9 @@ use { #[cfg(feature = "orchard")] use crate::PoolType; +#[cfg(feature = "pczt")] +use pczt::roles::{prover::Prover, signer::Signer}; + /// Trait that exposes the pool-specific types and operations necessary to run the /// single-shielded-pool tests on a given pool. /// @@ -162,6 +165,18 @@ pub trait ShieldedPoolTester { ) -> Option<(Note, Address, MemoBytes)>; fn received_note_count(summary: &ScanSummary) -> usize; + + #[cfg(feature = "pczt")] + fn add_proof_generation_keys( + pczt: pczt::Pczt, + usk: &UnifiedSpendingKey, + ) -> Result; + + #[cfg(feature = "pczt")] + fn apply_signatures_to_pczt( + signer: &mut Signer, + usk: &UnifiedSpendingKey, + ) -> Result<(), pczt::roles::signer::Error>; } /// Tests sending funds within the given shielded pool in a single transaction. @@ -494,6 +509,8 @@ pub fn send_multi_step_proposed_transfer( DSF: DataStoreFactory, ::AccountId: std::fmt::Debug, { + use zcash_primitives::transaction::components::transparent::builder::TransparentSigningSet; + use crate::data_api::{OutputOfSentTx, GAP_LIMIT}; let mut st = TestBuilder::new() @@ -715,6 +732,7 @@ pub fn send_multi_step_proposed_transfer( orchard_anchor: None, }, ); + let mut transparent_signing_set = TransparentSigningSet::new(); let (colliding_addr, _) = &known_addrs[10]; let utxo_value = (value - zip317::MINIMUM_FEE).unwrap(); assert_matches!( @@ -726,6 +744,7 @@ pub fn send_multi_step_proposed_transfer( .transparent() .derive_secret_key(Scope::External.into(), default_index) .unwrap(); + let pubkey = transparent_signing_set.add_key(sk); let outpoint = OutPoint::fake(); let txout = TxOut { script_pubkey: default_addr.script(), @@ -738,10 +757,16 @@ pub fn send_multi_step_proposed_transfer( ) .unwrap(); - assert_matches!(builder.add_transparent_input(sk, outpoint, txout), Ok(_)); + assert_matches!( + builder.add_transparent_input(pubkey, outpoint, txout), + Ok(_) + ); let test_prover = LocalTxProver::bundled(); let build_result = builder .build( + &transparent_signing_set, + &[], + &[], OsRng, &test_prover, &test_prover, @@ -3070,3 +3095,118 @@ pub fn metadata_queries_exclude_unwanted_notes( Some(5), ); } + +#[cfg(feature = "pczt")] +pub fn pczt_single_step( + ds_factory: DSF, + cache: impl TestCache, +) where + DSF: DataStoreFactory, + ::AccountId: serde::Serialize + serde::de::DeserializeOwned, +{ + use zcash_protocol::consensus::ZIP212_GRACE_PERIOD; + + let mut st = TestBuilder::new() + .with_data_store_factory(ds_factory) + .with_block_cache(cache) + .with_initial_chain_state(|_, network| { + // Initialize the chain state to after ZIP 212 became enforced. + let birthday_height = std::cmp::max( + network.activation_height(NetworkUpgrade::Nu5).unwrap(), + network.activation_height(NetworkUpgrade::Canopy).unwrap() + ZIP212_GRACE_PERIOD, + ); + + InitialChainState { + chain_state: ChainState::new( + birthday_height - 1, + BlockHash([5; 32]), + Frontier::empty(), + #[cfg(feature = "orchard")] + Frontier::empty(), + ), + prior_sapling_roots: vec![], + #[cfg(feature = "orchard")] + prior_orchard_roots: vec![], + } + }) + .with_account_having_current_birthday() + .build(); + + let account = st.test_account().cloned().unwrap(); + + let p0_fvk = P0::test_account_fvk(&st); + + let p1_fvk = P1::test_account_fvk(&st); + let p1_to = P1::fvk_default_address(&p1_fvk); + + // Only mine a block in P0 to ensure the transactions source is there. + let note_value = NonNegativeAmount::const_from_u64(350000); + st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value); + st.scan_cached_blocks(account.birthday().height(), 1); + + assert_eq!(st.get_total_balance(account.id()), note_value); + assert_eq!(st.get_spendable_balance(account.id(), 1), note_value); + + let transfer_amount = NonNegativeAmount::const_from_u64(200000); + let p0_to_p1 = TransactionRequest::new(vec![Payment::without_memo( + p1_to.to_zcash_address(st.network()), + transfer_amount, + )]) + .unwrap(); + + let input_selector = GreedyInputSelector::new(); + let change_strategy = + single_output_change_strategy(StandardFeeRule::Zip317, None, P0::SHIELDED_PROTOCOL); + let proposal0 = st + .propose_transfer( + account.id(), + &input_selector, + &change_strategy, + p0_to_p1, + NonZeroU32::new(1).unwrap(), + ) + .unwrap(); + + let _min_target_height = proposal0.min_target_height(); + assert_eq!(proposal0.steps().len(), 1); + + let create_proposed_result = st.create_pczt_from_proposal::( + account.id(), + OvkPolicy::Sender, + &proposal0, + ); + assert_matches!(&create_proposed_result, Ok(_)); + let pczt_created = create_proposed_result.unwrap(); + + // If we don't create proofs or signatures, we will fail to extract a transaction. + assert_matches!( + st.extract_and_store_transaction_from_pczt(pczt_created.clone()), + Err(Error::Pczt(data_api::error::PcztError::Extraction(_))) + ); + + // Add proof generation keys to Sapling spends. + let pczt_updated = P0::add_proof_generation_keys(pczt_created, account.usk()).unwrap(); + + // Create proofs. + let sapling_prover = LocalTxProver::bundled(); + let orchard_pk = ::orchard::circuit::ProvingKey::build(); + let pczt_proven = Prover::new(pczt_updated) + .create_orchard_proof(&orchard_pk) + .unwrap() + .create_sapling_proofs(&sapling_prover, &sapling_prover) + .unwrap() + .finish(); + + // Apply signatures. + let mut signer = Signer::new(pczt_proven).unwrap(); + P0::apply_signatures_to_pczt(&mut signer, account.usk()).unwrap(); + let pczt_authorized = signer.finish(); + + // Now we can extract the transaction. + let extract_and_store_result = st.extract_and_store_transaction_from_pczt(pczt_authorized); + assert_matches!(&extract_and_store_result, Ok(_)); + let txid = extract_and_store_result.unwrap(); + + let (h, _) = st.generate_next_block_including(txid); + st.scan_cached_blocks(h, 1); +} diff --git a/zcash_client_backend/src/data_api/testing/sapling.rs b/zcash_client_backend/src/data_api/testing/sapling.rs index fe082c224f..08b737923b 100644 --- a/zcash_client_backend/src/data_api/testing/sapling.rs +++ b/zcash_client_backend/src/data_api/testing/sapling.rs @@ -150,4 +150,61 @@ impl ShieldedPoolTester for SaplingPoolTester { fn received_note_count(summary: &ScanSummary) -> usize { summary.received_sapling_note_count() } + + #[cfg(feature = "pczt")] + fn add_proof_generation_keys( + pczt: pczt::Pczt, + usk: &UnifiedSpendingKey, + ) -> Result { + let extsk = Self::usk_to_sk(usk); + + Ok(pczt::roles::updater::Updater::new(pczt) + .update_sapling_with(|mut updater| { + let non_dummy_spends = updater + .bundle() + .spends() + .iter() + .enumerate() + .filter_map(|(index, spend)| { + // Dummy spends will already have a proof generation key. + spend.proof_generation_key().is_none().then_some(index) + }) + .collect::>(); + + // Assume all non-dummy spent notes are from the same account. + for index in non_dummy_spends { + updater.update_spend_with(index, |mut spend_updater| { + spend_updater.set_proof_generation_key(extsk.expsk.proof_generation_key()) + })?; + } + + Ok(()) + })? + .finish()) + } + + #[cfg(feature = "pczt")] + fn apply_signatures_to_pczt( + signer: &mut pczt::roles::signer::Signer, + usk: &UnifiedSpendingKey, + ) -> Result<(), pczt::roles::signer::Error> { + let extsk = Self::usk_to_sk(usk); + + // Figuring out which one is for us is hard. Let's just try signing all of them! + for index in 0.. { + match signer.sign_sapling(index, &extsk.expsk.ask) { + // Loop termination. + Err(pczt::roles::signer::Error::InvalidIndex) => break, + // Ignore any errors due to using the wrong key. + Ok(()) + | Err(pczt::roles::signer::Error::SaplingSign( + sapling::pczt::SignerError::WrongSpendAuthorizingKey, + )) => Ok(()), + // Raise any unexpected errors. + Err(e) => Err(e), + }?; + } + + Ok(()) + } } diff --git a/zcash_client_backend/src/data_api/wallet.rs b/zcash_client_backend/src/data_api/wallet.rs index 0d43844116..8d19e31898 100644 --- a/zcash_client_backend/src/data_api/wallet.rs +++ b/zcash_client_backend/src/data_api/wallet.rs @@ -41,6 +41,7 @@ use sapling::{ }; use shardtree::error::{QueryError, ShardTreeError}; use std::num::NonZeroU32; +use zcash_keys::keys::UnifiedFullViewingKey; use super::InputSource; use crate::{ @@ -63,7 +64,10 @@ use zcash_primitives::{ legacy::TransparentAddress, transaction::{ builder::{BuildConfig, BuildResult, Builder}, - components::{amount::NonNegativeAmount, sapling::zip212_enforcement, OutPoint}, + components::{ + amount::NonNegativeAmount, sapling::zip212_enforcement, + transparent::builder::TransparentSigningSet, OutPoint, + }, fees::FeeRule, Transaction, TxId, }, @@ -84,9 +88,80 @@ use { zcash_primitives::transaction::components::TxOut, }; +#[cfg(feature = "pczt")] +use { + crate::data_api::error::PcztError, + bip32::ChildNumber, + orchard::note_encryption::OrchardDomain, + pczt::roles::{ + creator::Creator, io_finalizer::IoFinalizer, spend_finalizer::SpendFinalizer, + tx_extractor::TransactionExtractor, updater::Updater, + }, + sapling::note_encryption::SaplingDomain, + serde::{Deserialize, Serialize}, + zcash_address::ZcashAddress, + zcash_note_encryption::try_output_recovery_with_pkd_esk, + zcash_primitives::transaction::components::transparent::pczt::Bip32Derivation, + zcash_protocol::{ + consensus::NetworkConstants, + value::{BalanceError, ZatBalance}, + }, +}; + pub mod input_selection; use input_selection::{GreedyInputSelector, InputSelector, InputSelectorError}; +#[cfg(feature = "pczt")] +const PROPRIETARY_PROPOSAL_INFO: &str = "zcash_client_backend:proposal_info"; +#[cfg(feature = "pczt")] +const PROPRIETARY_OUTPUT_INFO: &str = "zcash_client_backend:output_info"; + +/// Information about the proposal from which a PCZT was created. +/// +/// Stored under the proprietary field `PROPRIETARY_PROPOSAL_INFO`. +#[cfg(feature = "pczt")] +#[derive(Serialize, Deserialize)] +struct ProposalInfo { + from_account: AccountId, + target_height: u32, +} + +/// Reduced version of [`Recipient`] stored inside a PCZT. +/// +/// Stored under the proprietary field `PROPRIETARY_OUTPUT_INFO`. +#[cfg(feature = "pczt")] +#[derive(Serialize, Deserialize)] +enum PcztRecipient { + External(ZcashAddress), + EphemeralTransparent { + receiving_account: AccountId, + }, + InternalAccount { + receiving_account: AccountId, + external_address: Option, + }, +} + +#[cfg(feature = "pczt")] +impl PcztRecipient { + fn from_recipient(recipient: Recipient) -> Self { + match recipient { + Recipient::External(addr, _) => PcztRecipient::External(addr), + Recipient::EphemeralTransparent { + receiving_account, .. + } => PcztRecipient::EphemeralTransparent { receiving_account }, + Recipient::InternalAccount { + receiving_account, + external_address, + .. + } => PcztRecipient::InternalAccount { + receiving_account, + external_address, + }, + } + } +} + /// Scans a [`Transaction`] for any information that can be decrypted by the accounts in /// the wallet, and saves it to the wallet. pub fn decrypt_and_store_transaction( @@ -171,6 +246,17 @@ pub type ShieldErrT = Error< Infallible, >; +/// Errors that may be generated when extracting a transaction from a PCZT. +#[cfg(feature = "pczt")] +pub type ExtractErrT = Error< + ::Error, + ::Error, + Infallible, + Infallible, + Infallible, + N, +>; + /// Select transaction inputs, compute fees, and construct a proposal for a transaction or series /// of transactions that can then be authorized and made ready for submission to the network with /// [`create_proposed_transactions`]. @@ -427,8 +513,8 @@ where // Store the transactions only after creating all of them. This avoids undesired // retransmissions in case a transaction is stored and the creation of a subsequent // transaction fails. - let mut transactions = Vec::with_capacity(proposal.steps().len()); - let mut txids = Vec::with_capacity(proposal.steps().len()); + let mut transactions = Vec::with_capacity(step_results.len()); + let mut txids = Vec::with_capacity(step_results.len()); #[allow(unused_variables)] for (_, step_result) in step_results.iter() { let tx = step_result.build_result.transaction(); @@ -452,20 +538,45 @@ where Ok(NonEmpty::from_vec(txids).expect("proposal.steps is NonEmpty")) } +#[allow(clippy::type_complexity)] +struct BuildState<'a, P, AccountId> { + #[cfg(feature = "transparent-inputs")] + step_index: usize, + builder: Builder<'a, P, ()>, + #[cfg(feature = "transparent-inputs")] + transparent_input_addresses: HashMap, + #[cfg(feature = "orchard")] + orchard_output_meta: Vec<( + Recipient, + NonNegativeAmount, + Option, + )>, + sapling_output_meta: Vec<( + Recipient, + NonNegativeAmount, + Option, + )>, + transparent_output_meta: Vec<( + Recipient, + TransparentAddress, + NonNegativeAmount, + StepOutputIndex, + )>, + #[cfg(feature = "transparent-inputs")] + utxos_spent: Vec, +} + // `unused_transparent_outputs` maps `StepOutput`s for transparent outputs // that have not been consumed so far, to the corresponding pair of // `TransparentAddress` and `Outpoint`. #[allow(clippy::too_many_arguments)] #[allow(clippy::type_complexity)] -fn create_proposed_transaction( +fn build_proposed_transaction( wallet_db: &mut DbT, params: &ParamsT, - spend_prover: &impl SpendProver, - output_prover: &impl OutputProver, - usk: &UnifiedSpendingKey, + ufvk: &UnifiedFullViewingKey, account_id: ::AccountId, ovk_policy: OvkPolicy, - fee_rule: &FeeRuleT, min_target_height: BlockHeight, prior_step_results: &[(&Step, StepResult<::AccountId>)], proposal_step: &Step, @@ -474,7 +585,7 @@ fn create_proposed_transaction, ) -> Result< - StepResult<::AccountId>, + BuildState<'static, ParamsT, DbT::AccountId>, CreateErrT, > where @@ -534,26 +645,20 @@ where .notes() .iter() .filter_map(|selected| match selected.note() { - Note::Sapling(note) => { - let key = match selected.spending_key_scope() { - Scope::External => usk.sapling().clone(), - Scope::Internal => usk.sapling().derive_internal(), - }; - - sapling_tree - .witness_at_checkpoint_id_caching( - selected.note_commitment_tree_position(), - &inputs.anchor_height(), - ) - .and_then(|witness| { - witness.ok_or(ShardTreeError::Query( - QueryError::CheckpointPruned, - )) - }) - .map(|merkle_path| Some((key, note, merkle_path))) - .map_err(Error::from) - .transpose() - } + Note::Sapling(note) => sapling_tree + .witness_at_checkpoint_id_caching( + selected.note_commitment_tree_position(), + &inputs.anchor_height(), + ) + .and_then(|witness| { + witness + .ok_or(ShardTreeError::Query(QueryError::CheckpointPruned)) + }) + .map(|merkle_path| { + Some((selected.spending_key_scope(), note, merkle_path)) + }) + .map_err(Error::from) + .transpose(), #[cfg(feature = "orchard")] Note::Orchard(_) => None, }) @@ -627,13 +732,28 @@ where #[cfg(all(feature = "transparent-inputs", feature = "orchard"))] let has_shielded_inputs = !(sapling_inputs.is_empty() && orchard_inputs.is_empty()); - for (sapling_key, sapling_note, merkle_path) in sapling_inputs.into_iter() { - builder.add_sapling_spend(&sapling_key, sapling_note.clone(), merkle_path)?; + for (_sapling_key_scope, sapling_note, merkle_path) in sapling_inputs.into_iter() { + let key = match _sapling_key_scope { + Scope::External => ufvk.sapling().map(|k| k.fvk().clone()), + Scope::Internal => ufvk.sapling().map(|k| k.to_internal_fvk()), + }; + + builder.add_sapling_spend( + key.ok_or(Error::KeyNotAvailable(PoolType::SAPLING))?, + sapling_note.clone(), + merkle_path, + )?; } #[cfg(feature = "orchard")] for (orchard_note, merkle_path) in orchard_inputs.into_iter() { - builder.add_orchard_spend(usk.orchard(), *orchard_note, merkle_path.into())?; + builder.add_orchard_spend( + ufvk.orchard() + .cloned() + .ok_or(Error::KeyNotAvailable(PoolType::ORCHARD))?, + *orchard_note, + merkle_path.into(), + )?; } #[cfg(feature = "transparent-inputs")] @@ -668,23 +788,26 @@ where #[cfg(feature = "transparent-inputs")] let utxos_spent = { let mut utxos_spent: Vec = vec![]; - let add_transparent_input = - |builder: &mut Builder<_, _>, - utxos_spent: &mut Vec<_>, - address_metadata: &TransparentAddressMetadata, - outpoint: OutPoint, - txout: TxOut| - -> Result<(), CreateErrT> { - let secret_key = usk - .transparent() - .derive_secret_key(address_metadata.scope(), address_metadata.address_index()) - .expect("spending key derivation should not fail"); + let add_transparent_input = |builder: &mut Builder<_, _>, + utxos_spent: &mut Vec<_>, + address_metadata: &TransparentAddressMetadata, + outpoint: OutPoint, + txout: TxOut| + -> Result< + (), + CreateErrT, + > { + let pubkey = ufvk + .transparent() + .ok_or(Error::KeyNotAvailable(PoolType::Transparent))? + .derive_address_pubkey(address_metadata.scope(), address_metadata.address_index()) + .expect("spending key derivation should not fail"); - utxos_spent.push(outpoint.clone()); - builder.add_transparent_input(secret_key, outpoint, txout)?; + utxos_spent.push(outpoint.clone()); + builder.add_transparent_input(pubkey, outpoint, txout)?; - Ok(()) - }; + Ok(()) + }; for utxo in proposal_step.transparent_inputs() { add_transparent_input( @@ -723,12 +846,11 @@ where utxos_spent }; - #[cfg(feature = "orchard")] - let orchard_fvk: orchard::keys::FullViewingKey = usk.orchard().into(); - #[cfg(feature = "orchard")] let orchard_external_ovk = match &ovk_policy { - OvkPolicy::Sender => Some(orchard_fvk.to_ovk(orchard::keys::Scope::External)), + OvkPolicy::Sender => ufvk + .orchard() + .map(|fvk| fvk.to_ovk(orchard::keys::Scope::External)), OvkPolicy::Custom { orchard, .. } => Some(orchard.clone()), OvkPolicy::Discard => None, }; @@ -737,22 +859,17 @@ where let orchard_internal_ovk = || { #[cfg(feature = "transparent-inputs")] if proposal_step.is_shielding() { - return Some(orchard::keys::OutgoingViewingKey::from( - usk.transparent() - .to_account_pubkey() - .internal_ovk() - .as_bytes(), - )); + return ufvk + .transparent() + .map(|k| orchard::keys::OutgoingViewingKey::from(k.internal_ovk().as_bytes())); } - Some(orchard_fvk.to_ovk(Scope::Internal)) + ufvk.orchard().map(|k| k.to_ovk(Scope::Internal)) }; - let sapling_dfvk = usk.sapling().to_diversifiable_full_viewing_key(); - // Apply the outgoing viewing key policy. let sapling_external_ovk = match &ovk_policy { - OvkPolicy::Sender => Some(sapling_dfvk.to_ovk(Scope::External)), + OvkPolicy::Sender => ufvk.sapling().map(|k| k.to_ovk(Scope::External)), OvkPolicy::Custom { sapling, .. } => Some(*sapling), OvkPolicy::Discard => None, }; @@ -760,15 +877,12 @@ where let sapling_internal_ovk = || { #[cfg(feature = "transparent-inputs")] if proposal_step.is_shielding() { - return Some(sapling::keys::OutgoingViewingKey( - usk.transparent() - .to_account_pubkey() - .internal_ovk() - .as_bytes(), - )); + return ufvk + .transparent() + .map(|k| sapling::keys::OutgoingViewingKey(k.internal_ovk().as_bytes())); } - Some(sapling_dfvk.to_ovk(Scope::Internal)) + ufvk.sapling().map(|k| k.to_ovk(Scope::Internal)) }; #[cfg(feature = "orchard")] @@ -917,7 +1031,10 @@ where PoolType::Shielded(ShieldedProtocol::Sapling) => { builder.add_sapling_output( sapling_internal_ovk(), - sapling_dfvk.change_address().1, + ufvk.sapling() + .ok_or(Error::KeyNotAvailable(PoolType::SAPLING))? + .change_address() + .1, change_value.value(), memo.clone(), )?; @@ -939,7 +1056,9 @@ where { builder.add_orchard_output( orchard_internal_ovk(), - orchard_fvk.address_at(0u32, orchard::keys::Scope::Internal), + ufvk.orchard() + .ok_or(Error::KeyNotAvailable(PoolType::ORCHARD))? + .address_at(0u32, orchard::keys::Scope::Internal), change_value.value().into(), memo.clone(), )?; @@ -1001,76 +1120,156 @@ where } } + Ok(BuildState { + #[cfg(feature = "transparent-inputs")] + step_index, + builder, + #[cfg(feature = "transparent-inputs")] + transparent_input_addresses: cache, + #[cfg(feature = "orchard")] + orchard_output_meta, + sapling_output_meta, + transparent_output_meta, + #[cfg(feature = "transparent-inputs")] + utxos_spent, + }) +} + +// `unused_transparent_outputs` maps `StepOutput`s for transparent outputs +// that have not been consumed so far, to the corresponding pair of +// `TransparentAddress` and `Outpoint`. +#[allow(clippy::too_many_arguments)] +#[allow(clippy::type_complexity)] +fn create_proposed_transaction( + wallet_db: &mut DbT, + params: &ParamsT, + spend_prover: &impl SpendProver, + output_prover: &impl OutputProver, + usk: &UnifiedSpendingKey, + account_id: ::AccountId, + ovk_policy: OvkPolicy, + fee_rule: &FeeRuleT, + min_target_height: BlockHeight, + prior_step_results: &[(&Step, StepResult<::AccountId>)], + proposal_step: &Step, + #[cfg(feature = "transparent-inputs")] unused_transparent_outputs: &mut HashMap< + StepOutput, + (TransparentAddress, OutPoint), + >, +) -> Result< + StepResult<::AccountId>, + CreateErrT, +> +where + DbT: WalletWrite + WalletCommitmentTrees, + ParamsT: consensus::Parameters + Clone, + FeeRuleT: FeeRule, +{ + let build_state = build_proposed_transaction::<_, _, _, FeeRuleT, _, _>( + wallet_db, + params, + &usk.to_unified_full_viewing_key(), + account_id, + ovk_policy, + min_target_height, + prior_step_results, + proposal_step, + #[cfg(feature = "transparent-inputs")] + unused_transparent_outputs, + )?; + // Build the transaction with the specified fee rule - let build_result = builder.build(OsRng, spend_prover, output_prover, fee_rule)?; + #[cfg_attr(not(feature = "transparent-inputs"), allow(unused_mut))] + let mut transparent_signing_set = TransparentSigningSet::new(); + #[cfg(feature = "transparent-inputs")] + for (_, address_metadata) in build_state.transparent_input_addresses { + transparent_signing_set.add_key( + usk.transparent() + .derive_secret_key(address_metadata.scope(), address_metadata.address_index()) + .expect("spending key derivation should not fail"), + ); + } + let sapling_extsks = &[usk.sapling().clone(), usk.sapling().derive_internal()]; + #[cfg(feature = "orchard")] + let orchard_saks = &[usk.orchard().into()]; + #[cfg(not(feature = "orchard"))] + let orchard_saks = &[]; + let build_result = build_state.builder.build( + &transparent_signing_set, + sapling_extsks, + orchard_saks, + OsRng, + spend_prover, + output_prover, + fee_rule, + )?; + #[cfg(feature = "orchard")] + let orchard_fvk: orchard::keys::FullViewingKey = usk.orchard().into(); #[cfg(feature = "orchard")] let orchard_internal_ivk = orchard_fvk.to_ivk(orchard::keys::Scope::Internal); #[cfg(feature = "orchard")] - let orchard_outputs = - orchard_output_meta - .into_iter() - .enumerate() - .map(|(i, (recipient, value, memo))| { - let output_index = build_result - .orchard_meta() - .output_action_index(i) - .expect("An action should exist in the transaction for each Orchard output."); - - let recipient = recipient - .map_internal_account_note(|pool| { - assert!(pool == PoolType::ORCHARD); - build_result - .transaction() - .orchard_bundle() - .and_then(|bundle| { - bundle - .decrypt_output_with_key(output_index, &orchard_internal_ivk) - .map(|(note, _, _)| Note::Orchard(note)) - }) - }) - .internal_account_note_transpose_option() - .expect("Wallet-internal outputs must be decryptable with the wallet's IVK"); + let orchard_outputs = build_state.orchard_output_meta.into_iter().enumerate().map( + |(i, (recipient, value, memo))| { + let output_index = build_result + .orchard_meta() + .output_action_index(i) + .expect("An action should exist in the transaction for each Orchard output."); + + let recipient = recipient + .map_internal_account_note(|pool| { + assert!(pool == PoolType::ORCHARD); + build_result + .transaction() + .orchard_bundle() + .and_then(|bundle| { + bundle + .decrypt_output_with_key(output_index, &orchard_internal_ivk) + .map(|(note, _, _)| Note::Orchard(note)) + }) + }) + .internal_account_note_transpose_option() + .expect("Wallet-internal outputs must be decryptable with the wallet's IVK"); - SentTransactionOutput::from_parts(output_index, recipient, value, memo) - }); + SentTransactionOutput::from_parts(output_index, recipient, value, memo) + }, + ); + let sapling_dfvk = usk.sapling().to_diversifiable_full_viewing_key(); let sapling_internal_ivk = PreparedIncomingViewingKey::new(&sapling_dfvk.to_ivk(Scope::Internal)); - let sapling_outputs = - sapling_output_meta - .into_iter() - .enumerate() - .map(|(i, (recipient, value, memo))| { - let output_index = build_result - .sapling_meta() - .output_index(i) - .expect("An output should exist in the transaction for each Sapling payment."); - - let recipient = recipient - .map_internal_account_note(|pool| { - assert!(pool == PoolType::SAPLING); - build_result - .transaction() - .sapling_bundle() - .and_then(|bundle| { - try_sapling_note_decryption( - &sapling_internal_ivk, - &bundle.shielded_outputs()[output_index], - zip212_enforcement(params, min_target_height), - ) - .map(|(note, _, _)| Note::Sapling(note)) - }) - }) - .internal_account_note_transpose_option() - .expect("Wallet-internal outputs must be decryptable with the wallet's IVK"); + let sapling_outputs = build_state.sapling_output_meta.into_iter().enumerate().map( + |(i, (recipient, value, memo))| { + let output_index = build_result + .sapling_meta() + .output_index(i) + .expect("An output should exist in the transaction for each Sapling payment."); + + let recipient = recipient + .map_internal_account_note(|pool| { + assert!(pool == PoolType::SAPLING); + build_result + .transaction() + .sapling_bundle() + .and_then(|bundle| { + try_sapling_note_decryption( + &sapling_internal_ivk, + &bundle.shielded_outputs()[output_index], + zip212_enforcement(params, min_target_height), + ) + .map(|(note, _, _)| Note::Sapling(note)) + }) + }) + .internal_account_note_transpose_option() + .expect("Wallet-internal outputs must be decryptable with the wallet's IVK"); - SentTransactionOutput::from_parts(output_index, recipient, value, memo) - }); + SentTransactionOutput::from_parts(output_index, recipient, value, memo) + }, + ); let txid: [u8; 32] = build_result.transaction().txid().into(); assert_eq!( - transparent_output_meta.len(), + build_state.transparent_output_meta.len(), build_result .transaction() .transparent_bundle() @@ -1078,8 +1277,11 @@ where ); #[allow(unused_variables)] - let transparent_outputs = transparent_output_meta.into_iter().enumerate().map( - |(n, (recipient, address, value, step_output_index))| { + let transparent_outputs = build_state + .transparent_output_meta + .into_iter() + .enumerate() + .map(|(n, (recipient, address, value, step_output_index))| { // This assumes that transparent outputs are pushed onto `transparent_output_meta` // with the same indices they have in the transaction's transparent outputs. // We do not reorder transparent outputs; there is no reason to do so because it @@ -1089,12 +1291,11 @@ where let recipient = recipient.map_ephemeral_transparent_outpoint(|()| outpoint.clone()); #[cfg(feature = "transparent-inputs")] unused_transparent_outputs.insert( - StepOutput::new(step_index, step_output_index), + StepOutput::new(build_state.step_index, step_output_index), (address, outpoint), ); SentTransactionOutput::from_parts(n, recipient, value, None) - }, - ); + }); let mut outputs: Vec> = vec![]; #[cfg(feature = "orchard")] @@ -1107,10 +1308,645 @@ where outputs, fee_amount: proposal_step.balance().fee_required(), #[cfg(feature = "transparent-inputs")] - utxos_spent, + utxos_spent: build_state.utxos_spent, }) } +/// Constructs a transaction using the inputs supplied by the given proposal. +/// +/// Only single-step proposals are currently supported. +/// +/// Returns a partially-created Zcash transaction (PCZT) that is ready to be authorized. +/// You can use the following roles for this: +/// - [`pczt::roles::prover::Prover`] +/// - [`pczt::roles::signer::Signer`] (if you have local access to the spend authorizing +/// keys) +/// - [`pczt::roles::combiner::Combiner`] (if you create proofs and apply signatures in +/// parallel) +/// +/// Once the PCZT fully authorized, call [`extract_and_store_transaction_from_pczt`] to +/// finish transaction creation. +#[allow(clippy::too_many_arguments)] +#[allow(clippy::type_complexity)] +#[cfg(feature = "pczt")] +pub fn create_pczt_from_proposal( + wallet_db: &mut DbT, + params: &ParamsT, + account_id: ::AccountId, + ovk_policy: OvkPolicy, + proposal: &Proposal, +) -> Result> +where + DbT: WalletWrite + WalletCommitmentTrees, + ParamsT: consensus::Parameters + Clone, + FeeRuleT: FeeRule, + DbT::AccountId: serde::Serialize, +{ + let account = wallet_db + .get_account(account_id) + .map_err(Error::DataSource)? + .ok_or(Error::AccountIdNotRecognized)?; + let ufvk = account.ufvk().ok_or(Error::AccountCannotSpend)?; + let account_derivation = account.source().key_derivation(); + + // For now we only support turning single-step proposals into PCZTs. + if proposal.steps().len() > 1 { + return Err(Error::ProposalNotSupported); + } + let fee_rule = proposal.fee_rule(); + let min_target_height = proposal.min_target_height(); + let prior_step_results = &[]; + let proposal_step = proposal.steps().first(); + let unused_transparent_outputs = &mut HashMap::new(); + + let build_state = build_proposed_transaction::<_, _, _, FeeRuleT, _, _>( + wallet_db, + params, + ufvk, + account_id, + ovk_policy, + min_target_height, + prior_step_results, + proposal_step, + #[cfg(feature = "transparent-inputs")] + unused_transparent_outputs, + )?; + + // Build the transaction with the specified fee rule + let build_result = build_state.builder.build_for_pczt(OsRng, fee_rule)?; + + let created = Creator::build_from_parts(build_result.pczt_parts).ok_or(PcztError::Build)?; + + let io_finalized = IoFinalizer::new(created).finalize_io()?; + + #[cfg(feature = "orchard")] + let orchard_outputs = build_state + .orchard_output_meta + .into_iter() + .enumerate() + .map(|(i, (recipient, _, _))| { + let output_index = build_result + .orchard_meta + .output_action_index(i) + .expect("An action should exist in the transaction for each Orchard output."); + + (output_index, PcztRecipient::from_recipient(recipient)) + }) + .collect::>(); + + let sapling_outputs = build_state + .sapling_output_meta + .into_iter() + .enumerate() + .map(|(i, (recipient, _, _))| { + let output_index = build_result + .sapling_meta + .output_index(i) + .expect("An output should exist in the transaction for each Sapling output."); + + (output_index, PcztRecipient::from_recipient(recipient)) + }) + .collect::>(); + + let pczt = Updater::new(io_finalized) + .update_global_with(|mut updater| { + updater.set_proprietary( + PROPRIETARY_PROPOSAL_INFO.into(), + postcard::to_allocvec(&ProposalInfo:: { + from_account: account_id, + target_height: proposal.min_target_height().into(), + }) + .expect("postcard encoding of PCZT proposal metadata should not fail"), + ) + }) + .update_orchard_with(|mut updater| { + for index in 0..updater.bundle().actions().len() { + updater.update_action_with(index, |mut action_updater| { + // If the account has a known derivation, add the Orchard key path to the PCZT. + if let Some(derivation) = account_derivation { + // All spent notes are from the same account. + action_updater.set_spend_zip32_derivation( + orchard::pczt::Zip32Derivation::parse( + derivation.seed_fingerprint().to_bytes(), + vec![ + zip32::ChildIndex::hardened(32).index(), + zip32::ChildIndex::hardened(params.network_type().coin_type()) + .index(), + zip32::ChildIndex::hardened(u32::from( + derivation.account_index(), + )) + .index(), + ], + ) + .expect("valid"), + ); + } + + if let Some(pczt_recipient) = orchard_outputs.get(&index) { + action_updater.set_output_proprietary( + PROPRIETARY_OUTPUT_INFO.into(), + postcard::to_allocvec(pczt_recipient).expect( + "postcard encoding of PCZT recipient metadata should not fail", + ), + ); + } + + Ok(()) + })?; + } + Ok(()) + })? + .update_sapling_with(|mut updater| { + // If the account has a known derivation, add the Sapling key path to the PCZT. + if let Some(derivation) = account_derivation { + let non_dummy_spends = updater + .bundle() + .spends() + .iter() + .enumerate() + .filter_map(|(index, spend)| { + // Dummy spends will already have a proof generation key. + spend.proof_generation_key().is_none().then_some(index) + }) + .collect::>(); + + for index in non_dummy_spends { + updater.update_spend_with(index, |mut spend_updater| { + // All non-dummy spent notes are from the same account. + spend_updater.set_zip32_derivation( + sapling::pczt::Zip32Derivation::parse( + derivation.seed_fingerprint().to_bytes(), + vec![ + zip32::ChildIndex::hardened(32).index(), + zip32::ChildIndex::hardened(params.network_type().coin_type()) + .index(), + zip32::ChildIndex::hardened(u32::from( + derivation.account_index(), + )) + .index(), + ], + ) + .expect("valid"), + ); + Ok(()) + })?; + } + } + + for index in 0..updater.bundle().outputs().len() { + if let Some(pczt_recipient) = sapling_outputs.get(&index) { + updater.update_output_with(index, |mut output_updater| { + output_updater.set_proprietary( + PROPRIETARY_OUTPUT_INFO.into(), + postcard::to_allocvec(pczt_recipient).expect( + "postcard encoding of PCZT recipient metadata should not fail", + ), + ); + Ok(()) + })?; + } + } + + Ok(()) + })? + .update_transparent_with(|mut updater| { + // If the account has a known derivation, add the transparent key paths to the PCZT. + if let Some(derivation) = account_derivation { + // Match address metadata to the inputs that spend from those addresses. + let inputs_to_update = updater + .bundle() + .inputs() + .iter() + .enumerate() + .filter_map(|(index, input)| { + build_state + .transparent_input_addresses + .get( + &input + .script_pubkey() + .address() + .expect("we created this with a supported transparent address"), + ) + .map(|address_metadata| { + ( + index, + address_metadata.scope(), + address_metadata.address_index(), + ) + }) + }) + .collect::>(); + + for (index, scope, address_index) in inputs_to_update { + updater.update_input_with(index, |mut input_updater| { + let pubkey = ufvk + .transparent() + .expect("we derived this successfully in build_proposed_transaction") + .derive_address_pubkey(scope, address_index) + .expect("spending key derivation should not fail"); + + input_updater.set_bip32_derivation( + pubkey.serialize(), + Bip32Derivation::parse( + derivation.seed_fingerprint().to_bytes(), + vec![ + // Transparent uses BIP 44 derivation. + 44 | ChildNumber::HARDENED_FLAG, + params.network_type().coin_type() | ChildNumber::HARDENED_FLAG, + u32::from(derivation.account_index()) + | ChildNumber::HARDENED_FLAG, + ChildNumber::from(scope).into(), + ChildNumber::from(address_index).into(), + ], + ) + .expect("valid"), + ); + Ok(()) + })?; + } + } + + assert_eq!( + build_state.transparent_output_meta.len(), + updater.bundle().outputs().len(), + ); + for (index, (recipient, _, _, _)) in + build_state.transparent_output_meta.into_iter().enumerate() + { + updater.update_output_with(index, |mut output_updater| { + output_updater.set_proprietary( + PROPRIETARY_OUTPUT_INFO.into(), + postcard::to_allocvec(&PcztRecipient::from_recipient(recipient)) + .expect("postcard encoding of pczt recipient metadata should not fail"), + ); + Ok(()) + })?; + } + + Ok(()) + })? + .finish(); + + Ok(pczt) +} + +/// Finalizes the given PCZT, and persists the transaction to the wallet database. +/// +/// The PCZT should have been created via [`create_pczt_from_proposal`], which adds +/// metadata necessary for the wallet backend. +/// +/// Returns the transaction ID for the resulting transaction. +#[cfg(feature = "pczt")] +pub fn extract_and_store_transaction_from_pczt( + wallet_db: &mut DbT, + pczt: pczt::Pczt, + spend_vk: &sapling::circuit::SpendVerifyingKey, + output_vk: &sapling::circuit::OutputVerifyingKey, + #[cfg(feature = "orchard")] orchard_vk: &orchard::circuit::VerifyingKey, +) -> Result> +where + DbT: WalletWrite + WalletCommitmentTrees, + DbT::AccountId: serde::de::DeserializeOwned, +{ + use std::collections::BTreeMap; + use zcash_note_encryption::{Domain, ShieldedOutput, ENC_CIPHERTEXT_SIZE}; + + let finalized = SpendFinalizer::new(pczt).finalize_spends()?; + + let proposal_info = finalized + .global() + .proprietary() + .get(PROPRIETARY_PROPOSAL_INFO) + .ok_or_else(|| PcztError::Invalid("PCZT missing proprietary proposal info field".into())) + .and_then(|v| { + postcard::from_bytes::>(v).map_err(|e| { + PcztError::Invalid(format!( + "Postcard decoding of proprietary proposal info failed: {}", + e + )) + }) + })?; + + let orchard_output_info = finalized + .orchard() + .actions() + .iter() + .map(|act| { + let note = || { + let recipient = + act.output().recipient().as_ref().and_then(|b| { + ::orchard::Address::from_raw_address_bytes(b).into_option() + })?; + let value = act + .output() + .value() + .map(orchard::value::NoteValue::from_raw)?; + let rho = orchard::note::Rho::from_bytes(act.spend().nullifier()).into_option()?; + let rseed = act.output().rseed().as_ref().and_then(|rseed| { + orchard::note::RandomSeed::from_bytes(*rseed, &rho).into_option() + })?; + + orchard::Note::from_parts(recipient, value, rho, rseed).into_option() + }; + + let pczt_recipient = act + .output() + .proprietary() + .get(PROPRIETARY_OUTPUT_INFO) + .map(|v| postcard::from_bytes::>(v)) + .transpose()?; + + // If the pczt recipient is not present, this is a dummy note; if the note is not + // present, then the PCZT has been pruned to make this output unrecoverable and so we + // also ignore it. + Ok(pczt_recipient.zip(note())) + }) + .collect::, _>>() + .map_err(|e: postcard::Error| { + PcztError::Invalid(format!( + "Postcard decoding of proprietary output info failed: {}", + e + )) + })?; + + let sapling_output_info = finalized + .sapling() + .outputs() + .iter() + .map(|out| { + let note = || { + let recipient = out + .recipient() + .as_ref() + .and_then(::sapling::PaymentAddress::from_bytes)?; + let value = out.value().map(::sapling::value::NoteValue::from_raw)?; + let rseed = out + .rseed() + .as_ref() + .cloned() + .map(::sapling::note::Rseed::AfterZip212)?; + + Some(::sapling::Note::from_parts(recipient, value, rseed)) + }; + + let pczt_recipient = out + .proprietary() + .get(PROPRIETARY_OUTPUT_INFO) + .map(|v| postcard::from_bytes::>(v)) + .transpose()?; + + // If the pczt recipient is not present, this is a dummy note; if the note is not + // present, then the PCZT has been pruned to make this output unrecoverable and so we + // also ignore it. + Ok(pczt_recipient.zip(note())) + }) + .collect::, _>>() + .map_err(|e: postcard::Error| { + PcztError::Invalid(format!( + "Postcard decoding of proprietary output info failed: {}", + e + )) + })?; + + let transparent_output_info = finalized + .transparent() + .outputs() + .iter() + .map(|out| { + out.proprietary() + .get(PROPRIETARY_OUTPUT_INFO) + .map(|v| postcard::from_bytes::>(v)) + .transpose() + }) + .collect::, _>>() + .map_err(|e: postcard::Error| { + PcztError::Invalid(format!( + "Postcard decoding of proprietary output info failed: {}", + e + )) + })?; + + let utxos_map = finalized + .transparent() + .inputs() + .iter() + .map(|input| { + ZatBalance::from_u64(*input.value()).map(|value| { + ( + OutPoint::new(*input.prevout_txid(), *input.prevout_index()), + value, + ) + }) + }) + .collect::, _>>()?; + + let transaction = TransactionExtractor::new(finalized) + .with_sapling(spend_vk, output_vk) + .with_orchard(orchard_vk) + .extract()?; + let txid = transaction.txid(); + + #[allow(clippy::too_many_arguments)] + fn to_sent_transaction_output< + AccountId: Copy, + D: Domain, + O: ShieldedOutput, + DbT: WalletRead + WalletCommitmentTrees, + N, + >( + domain: D, + note: D::Note, + output: &O, + output_pool: ShieldedProtocol, + output_index: usize, + pczt_recipient: PcztRecipient, + note_value: impl Fn(&D::Note) -> u64, + memo_bytes: impl Fn(&D::Memo) -> &[u8; 512], + wallet_note: impl Fn(D::Note) -> Note, + ) -> Result, ExtractErrT> { + let pk_d = D::get_pk_d(¬e); + let esk = D::derive_esk(¬e).expect("notes are post-ZIP 212"); + let memo = try_output_recovery_with_pkd_esk(&domain, pk_d, esk, output).map(|(_, _, m)| { + MemoBytes::from_bytes(memo_bytes(&m)).expect("Memo is the correct length.") + }); + + let note_value = NonNegativeAmount::try_from(note_value(¬e))?; + let recipient = match pczt_recipient { + PcztRecipient::External(addr) => { + Ok(Recipient::External(addr, PoolType::Shielded(output_pool))) + } + PcztRecipient::EphemeralTransparent { .. } => Err(PcztError::Invalid( + "shielded output cannot be EphemeralTransparent".into(), + )), + PcztRecipient::InternalAccount { + receiving_account, + external_address, + } => Ok(Recipient::InternalAccount { + receiving_account, + external_address, + note: wallet_note(note), + }), + }?; + + Ok(SentTransactionOutput::from_parts( + output_index, + recipient, + note_value, + memo, + )) + } + + #[cfg(feature = "orchard")] + let orchard_outputs = transaction + .orchard_bundle() + .map(|bundle| { + assert_eq!(bundle.actions().len(), orchard_output_info.len()); + bundle + .actions() + .iter() + .zip(orchard_output_info) + .enumerate() + .filter_map(|(output_index, (action, output_info))| { + output_info.map(|(pczt_recipient, note)| { + let domain = OrchardDomain::for_action(action); + to_sent_transaction_output::<_, _, _, DbT, _>( + domain, + note, + action, + ShieldedProtocol::Orchard, + output_index, + pczt_recipient, + |note| note.value().inner(), + |memo| memo, + Note::Orchard, + ) + }) + }) + .collect::, _>>() + }) + .transpose()?; + + let sapling_outputs = transaction + .sapling_bundle() + .map(|bundle| { + assert_eq!(bundle.shielded_outputs().len(), sapling_output_info.len()); + bundle + .shielded_outputs() + .iter() + .zip(sapling_output_info) + .enumerate() + .filter_map(|(output_index, (action, output_info))| { + output_info.map(|(pczt_recipient, note)| { + let domain = + SaplingDomain::new(sapling::note_encryption::Zip212Enforcement::On); + to_sent_transaction_output::<_, _, _, DbT, _>( + domain, + note, + action, + ShieldedProtocol::Sapling, + output_index, + pczt_recipient, + |note| note.value().inner(), + |memo| memo, + Note::Sapling, + ) + }) + }) + .collect::, _>>() + }) + .transpose()?; + + #[allow(unused_variables)] + let transparent_outputs = transaction + .transparent_bundle() + .map(|bundle| { + assert_eq!(bundle.vout.len(), transparent_output_info.len()); + bundle + .vout + .iter() + .zip(transparent_output_info) + .enumerate() + .filter_map(|(output_index, (output, output_info))| { + output_info.map(|pczt_recipient| { + // This assumes that transparent outputs are pushed onto `transparent_output_meta` + // with the same indices they have in the transaction's transparent outputs. + // We do not reorder transparent outputs; there is no reason to do so because it + // would not usefully improve privacy. + let outpoint = OutPoint::new(txid.into(), output_index as u32); + + let recipient = match pczt_recipient { + PcztRecipient::External(addr) => { + Ok(Recipient::External(addr, PoolType::Transparent)) + } + PcztRecipient::EphemeralTransparent { receiving_account } => output + .recipient_address() + .ok_or(PcztError::Invalid( + "Ephemeral outputs cannot have a non-standard script_pubkey" + .into(), + )) + .map(|ephemeral_address| Recipient::EphemeralTransparent { + receiving_account, + ephemeral_address, + outpoint_metadata: outpoint, + }), + PcztRecipient::InternalAccount { + receiving_account, + external_address, + } => Err(PcztError::Invalid( + "Transparent output cannot be InternalAccount".into(), + )), + }?; + + Ok(SentTransactionOutput::from_parts( + output_index, + recipient, + output.value, + None, + )) + }) + }) + .collect::, ExtractErrT>>() + }) + .transpose()?; + + let mut outputs: Vec> = vec![]; + #[cfg(feature = "orchard")] + outputs.extend(orchard_outputs.into_iter().flatten()); + outputs.extend(sapling_outputs.into_iter().flatten()); + outputs.extend(transparent_outputs.into_iter().flatten()); + + let fee_amount = NonNegativeAmount::try_from(transaction.fee_paid(|outpoint| { + utxos_map + .get(outpoint) + .copied() + // Error doesn't matter, this can never happen because we constructed the + // UTXOs map and the transaction from the same PCZT. + .ok_or(BalanceError::Overflow) + })?)?; + + // We don't need the spent UTXOs to be in transaction order. + let utxos_spent = utxos_map.into_keys().collect::>(); + + let created = time::OffsetDateTime::now_utc(); + + let transactions = vec![SentTransaction::new( + &transaction, + created, + BlockHeight::from_u32(proposal_info.target_height), + proposal_info.from_account, + &outputs, + fee_amount, + #[cfg(feature = "transparent-inputs")] + &utxos_spent, + )]; + + wallet_db + .store_transactions_to_be_sent(&transactions) + .map_err(Error::DataSource)?; + + Ok(txid) +} + /// Constructs a transaction that consumes available transparent UTXOs belonging to the specified /// secret key, and sends them to the most-preferred receiver of the default internal address for /// the provided Unified Spending Key. diff --git a/zcash_client_sqlite/Cargo.toml b/zcash_client_sqlite/Cargo.toml index 9606b9ac3d..43d860ea91 100644 --- a/zcash_client_sqlite/Cargo.toml +++ b/zcash_client_sqlite/Cargo.toml @@ -49,6 +49,7 @@ nonempty.workspace = true prost.workspace = true group.workspace = true jubjub.workspace = true +serde = { workspace = true, optional = true } # - Secret management secrecy.workspace = true @@ -125,6 +126,9 @@ transparent-inputs = [ "zcash_client_backend/transparent-inputs" ] +## Enables `serde` derives for certain types. +serde = ["dep:serde", "uuid/serde"] + #! ### Experimental features ## Exposes unstable APIs. Their behaviour may change at any time. @@ -133,5 +137,9 @@ unstable = ["zcash_client_backend/unstable"] ## A feature used to isolate tests that are expensive to run. Test-only. expensive-tests = [] +## A feature used to enable PCZT-specific tests without interfering with the +## protocol-specific flags. Test-only. +pczt-tests = ["serde", "zcash_client_backend/pczt"] + [lib] bench = false diff --git a/zcash_client_sqlite/src/lib.rs b/zcash_client_sqlite/src/lib.rs index 1e27c54986..a6f02d51e1 100644 --- a/zcash_client_sqlite/src/lib.rs +++ b/zcash_client_sqlite/src/lib.rs @@ -177,7 +177,8 @@ pub(crate) const UA_TRANSPARENT: bool = true; /// - Restoring a wallet from a backed-up seed. /// - Importing the same viewing key into two different wallet instances. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)] -pub struct AccountUuid(Uuid); +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AccountUuid(#[cfg_attr(feature = "serde", serde(with = "uuid::serde::compact"))] Uuid); impl ConditionallySelectable for AccountUuid { fn conditional_select(a: &Self, b: &Self, choice: subtle::Choice) -> Self { diff --git a/zcash_client_sqlite/src/testing/pool.rs b/zcash_client_sqlite/src/testing/pool.rs index 726a377034..1b5132a36f 100644 --- a/zcash_client_sqlite/src/testing/pool.rs +++ b/zcash_client_sqlite/src/testing/pool.rs @@ -261,3 +261,11 @@ pub(crate) fn metadata_queries_exclude_unwanted_notes() { BlockCache::new(), ) } + +#[cfg(feature = "pczt-tests")] +pub(crate) fn pczt_single_step() { + zcash_client_backend::data_api::testing::pool::pczt_single_step::( + TestDbFactory::default(), + BlockCache::new(), + ) +} diff --git a/zcash_client_sqlite/src/wallet.rs b/zcash_client_sqlite/src/wallet.rs index 650964a2fe..532b4f7cec 100644 --- a/zcash_client_sqlite/src/wallet.rs +++ b/zcash_client_sqlite/src/wallet.rs @@ -74,7 +74,6 @@ use zcash_client_backend::data_api::{ AccountPurpose, DecryptedTransaction, Progress, TransactionDataRequest, TransactionStatus, Zip32Derivation, }; - use zip32::fingerprint::SeedFingerprint; use std::collections::{HashMap, HashSet}; diff --git a/zcash_client_sqlite/src/wallet/init/migrations/receiving_key_scopes.rs b/zcash_client_sqlite/src/wallet/init/migrations/receiving_key_scopes.rs index 778ea22c28..7822a076ca 100644 --- a/zcash_client_sqlite/src/wallet/init/migrations/receiving_key_scopes.rs +++ b/zcash_client_sqlite/src/wallet/init/migrations/receiving_key_scopes.rs @@ -306,7 +306,10 @@ mod tests { memo::MemoBytes, transaction::{ builder::{BuildConfig, BuildResult, Builder}, - components::{amount::NonNegativeAmount, transparent}, + components::{ + amount::NonNegativeAmount, + transparent::{self, builder::TransparentSigningSet}, + }, fees::fixed, Transaction, }, @@ -364,11 +367,14 @@ mod tests { orchard_anchor: None, }, ); + let mut transparent_signing_set = TransparentSigningSet::new(); builder .add_transparent_input( - usk0.transparent() - .derive_external_secret_key(NonHardenedChildIndex::ZERO) - .unwrap(), + transparent_signing_set.add_key( + usk0.transparent() + .derive_external_secret_key(NonHardenedChildIndex::ZERO) + .unwrap(), + ), transparent::OutPoint::fake(), transparent::TxOut { value: NonNegativeAmount::const_from_u64(EXTERNAL_VALUE + INTERNAL_VALUE), @@ -402,6 +408,9 @@ mod tests { let prover = LocalTxProver::bundled(); let res = builder .build( + &transparent_signing_set, + &[], + &[], OsRng, &prover, &prover, diff --git a/zcash_client_sqlite/src/wallet/orchard.rs b/zcash_client_sqlite/src/wallet/orchard.rs index 2c96503aa5..e2c6015f36 100644 --- a/zcash_client_sqlite/src/wallet/orchard.rs +++ b/zcash_client_sqlite/src/wallet/orchard.rs @@ -526,4 +526,16 @@ pub(crate) mod tests { fn multi_pool_checkpoints_with_pruning() { testing::pool::multi_pool_checkpoints_with_pruning::() } + + #[cfg(feature = "pczt-tests")] + #[test] + fn pczt_single_step_orchard_only() { + testing::pool::pczt_single_step::() + } + + #[cfg(feature = "pczt-tests")] + #[test] + fn pczt_single_step_orchard_to_sapling() { + testing::pool::pczt_single_step::() + } } diff --git a/zcash_client_sqlite/src/wallet/sapling.rs b/zcash_client_sqlite/src/wallet/sapling.rs index f37b2b4987..91b5f6e8c0 100644 --- a/zcash_client_sqlite/src/wallet/sapling.rs +++ b/zcash_client_sqlite/src/wallet/sapling.rs @@ -543,4 +543,16 @@ pub(crate) mod tests { fn multi_pool_checkpoints_with_pruning() { testing::pool::multi_pool_checkpoints_with_pruning::() } + + #[cfg(feature = "pczt-tests")] + #[test] + fn pczt_single_step_sapling_only() { + testing::pool::pczt_single_step::() + } + + #[cfg(feature = "pczt-tests")] + #[test] + fn pczt_single_step_sapling_to_orchard() { + testing::pool::pczt_single_step::() + } } diff --git a/zcash_extensions/src/transparent/demo.rs b/zcash_extensions/src/transparent/demo.rs index 42b29d2b4b..84054ccc8f 100644 --- a/zcash_extensions/src/transparent/demo.rs +++ b/zcash_extensions/src/transparent/demo.rs @@ -491,6 +491,7 @@ mod tests { builder::{BuildConfig, Builder}, components::{ amount::{Amount, NonNegativeAmount}, + transparent::builder::TransparentSigningSet, tze::{Authorized, Bundle, OutPoint, TzeIn, TzeOut}, }, fees::{fixed, zip317::MINIMUM_FEE}, @@ -798,7 +799,9 @@ mod tests { // create some inputs to spend let extsk = ExtendedSpendingKey::master(&[]); + let dfvk = extsk.to_diversifiable_full_viewing_key(); let to = extsk.default_address().1; + let sapling_extsks = &[extsk]; let note1 = to.create_note( sapling::value::NoteValue::from_raw(110000), Rseed::BeforeZip212(jubjub::Fr::random(&mut rng)), @@ -812,7 +815,7 @@ mod tests { let mut builder_a = demo_builder(tx_height, witness1.root().into()); builder_a - .add_sapling_spend::(&extsk, note1, witness1.path().unwrap()) + .add_sapling_spend::(dfvk.fvk().clone(), note1, witness1.path().unwrap()) .unwrap(); let value = NonNegativeAmount::const_from_u64(100000); @@ -823,7 +826,15 @@ mod tests { .unwrap(); let res_a = builder_a .txn_builder - .build_zfuture(OsRng, &prover, &prover, &fee_rule) + .build_zfuture( + &TransparentSigningSet::new(), + sapling_extsks, + &[], + OsRng, + &prover, + &prover, + &fee_rule, + ) .map_err(|e| format!("build failure: {:?}", e)) .unwrap(); let tze_a = res_a.transaction().tze_bundle().unwrap(); @@ -844,7 +855,15 @@ mod tests { .unwrap(); let res_b = builder_b .txn_builder - .build_zfuture(OsRng, &prover, &prover, &fee_rule) + .build_zfuture( + &TransparentSigningSet::new(), + sapling_extsks, + &[], + OsRng, + &prover, + &prover, + &fee_rule, + ) .map_err(|e| format!("build failure: {:?}", e)) .unwrap(); let tze_b = res_b.transaction().tze_bundle().unwrap(); @@ -872,7 +891,15 @@ mod tests { let res_c = builder_c .txn_builder - .build_zfuture(OsRng, &prover, &prover, &fee_rule) + .build_zfuture( + &TransparentSigningSet::new(), + sapling_extsks, + &[], + OsRng, + &prover, + &prover, + &fee_rule, + ) .map_err(|e| format!("build failure: {:?}", e)) .unwrap(); let tze_c = res_c.transaction().tze_bundle().unwrap(); diff --git a/zcash_primitives/CHANGELOG.md b/zcash_primitives/CHANGELOG.md index 4d4e2df42b..af6e72fdc1 100644 --- a/zcash_primitives/CHANGELOG.md +++ b/zcash_primitives/CHANGELOG.md @@ -8,6 +8,7 @@ and this library adheres to Rust's notion of ## [Unreleased] ### Added +- `zcash_primitives::legacy::Script::address` - `zcash_primitives::transaction` - `TransactionData::try_map_bundles` - `builder::{PcztResult, PcztParts}` @@ -16,8 +17,28 @@ and this library adheres to Rust's notion of - `pczt` module. - `EffectsOnly` - `impl MapAuth for ()` + - `builder::TransparentSigningSet` - `sighash::SighashType` +### Changed +- `zcash_primitives::transaction::components::transparent`: + - `builder::TransparentBuilder::add_input` now takes `secp256k1::PublicKey` + instead of `secp256k1::SecretKey`. + - `Bundle::apply_signatures` now takes an additional argument + `&TransparentSigningSet`. + - `builder::Error` has a new variant `MissingSigningKey`. +- `zcash_primitives::transaction::builder`: + - `Builder::add_orchard_spend` now takes `orchard::keys::FullViewingKey` + instead of `&orchard::keys::SpendingKey`. + - `Builder::add_sapling_spend` now takes `sapling::keys::FullViewingKey` + instead of `&sapling::zip32::ExtendedSpendingKey`. + - `Builder::add_transparent_input` now takes `secp256k1::PublicKey` instead of + `secp256k1::SecretKey`. + - `Builder::build` now takes several additional arguments: + - `&TransparentSigningSet` + - `&[sapling::zip32::ExtendedSpendingKey]` + - `&[orchard::keys::SpendAuthorizingKey]` + ## [0.20.0] - 2024-11-14 ### Added diff --git a/zcash_primitives/benches/note_decryption.rs b/zcash_primitives/benches/note_decryption.rs index 99048cb843..c18b7328e3 100644 --- a/zcash_primitives/benches/note_decryption.rs +++ b/zcash_primitives/benches/note_decryption.rs @@ -46,7 +46,7 @@ fn bench_note_decryption(c: &mut Criterion) { .add_output(None, pa, NoteValue::from_raw(100), None) .unwrap(); let (bundle, _) = builder - .build::(&mut rng) + .build::(&[], &mut rng) .unwrap() .unwrap(); bundle.shielded_outputs()[0].clone() diff --git a/zcash_primitives/src/legacy.rs b/zcash_primitives/src/legacy.rs index b01c6d1471..efc766fcc5 100644 --- a/zcash_primitives/src/legacy.rs +++ b/zcash_primitives/src/legacy.rs @@ -329,7 +329,7 @@ impl Script { } /// Returns the address that this Script contains, if any. - pub(crate) fn address(&self) -> Option { + pub fn address(&self) -> Option { if self.0.len() == 25 && self.0[0..3] == [OpCode::Dup as u8, OpCode::Hash160 as u8, 0x14] && self.0[23..25] == [OpCode::EqualVerify as u8, OpCode::CheckSig as u8] diff --git a/zcash_primitives/src/legacy/keys.rs b/zcash_primitives/src/legacy/keys.rs index 01b40eeb14..1e3336a237 100644 --- a/zcash_primitives/src/legacy/keys.rs +++ b/zcash_primitives/src/legacy/keys.rs @@ -247,6 +247,20 @@ impl AccountPubKey { .map(EphemeralIvk) } + /// Derives the BIP44 public key at the "address level" path corresponding to the given scope + /// and address index. + pub fn derive_address_pubkey( + &self, + scope: TransparentKeyScope, + address_index: NonHardenedChildIndex, + ) -> Result { + Ok(*self + .0 + .derive_child(scope.into())? + .derive_child(address_index.into())? + .public_key()) + } + /// Derives the internal ovk and external ovk corresponding to this /// transparent fvk. As specified in [ZIP 316][transparent-ovk]. /// diff --git a/zcash_primitives/src/transaction/builder.rs b/zcash_primitives/src/transaction/builder.rs index 855f4c2335..1396bd8901 100644 --- a/zcash_primitives/src/transaction/builder.rs +++ b/zcash_primitives/src/transaction/builder.rs @@ -53,6 +53,7 @@ use crate::{ use super::components::amount::NonNegativeAmount; use super::components::sapling::zip212_enforcement; +use super::components::transparent::builder::TransparentSigningSet; /// Since Blossom activation, the default transaction expiry delta should be 40 blocks. /// @@ -306,12 +307,6 @@ pub struct Builder<'a, P, U: sapling::builder::ProverProgress> { transparent_builder: TransparentBuilder, sapling_builder: Option, orchard_builder: Option, - // TODO: In the future, instead of taking the spending keys as arguments when calling - // `add_sapling_spend` or `add_orchard_spend`, we will build an unauthorized, unproven - // transaction, and then the caller will be responsible for using the spending keys or their - // derivatives for proving and signing to complete transaction creation. - sapling_asks: Vec, - orchard_saks: Vec, #[cfg(zcash_unstable = "zfuture")] tze_builder: TzeBuilder<'a, TransactionData>, #[cfg(not(zcash_unstable = "zfuture"))] @@ -395,8 +390,6 @@ impl<'a, P: consensus::Parameters> Builder<'a, P, ()> { transparent_builder: TransparentBuilder::empty(), sapling_builder, orchard_builder, - sapling_asks: vec![], - orchard_saks: Vec::new(), #[cfg(zcash_unstable = "zfuture")] tze_builder: TzeBuilder::empty(), #[cfg(not(zcash_unstable = "zfuture"))] @@ -423,8 +416,6 @@ impl<'a, P: consensus::Parameters> Builder<'a, P, ()> { transparent_builder: self.transparent_builder, sapling_builder: self.sapling_builder, orchard_builder: self.orchard_builder, - sapling_asks: self.sapling_asks, - orchard_saks: self.orchard_saks, tze_builder: self.tze_builder, progress_notifier, } @@ -438,16 +429,12 @@ impl<'a, P: consensus::Parameters, U: sapling::builder::ProverProgress> Builder< /// the given note. pub fn add_orchard_spend( &mut self, - sk: &orchard::keys::SpendingKey, + fvk: orchard::keys::FullViewingKey, note: orchard::Note, merkle_path: orchard::tree::MerklePath, ) -> Result<(), Error> { if let Some(builder) = self.orchard_builder.as_mut() { - builder.add_spend(orchard::keys::FullViewingKey::from(sk), note, merkle_path)?; - - self.orchard_saks - .push(orchard::keys::SpendAuthorizingKey::from(sk)); - + builder.add_spend(fvk, note, merkle_path)?; Ok(()) } else { Err(Error::OrchardBuilderNotAvailable) @@ -480,14 +467,12 @@ impl<'a, P: consensus::Parameters, U: sapling::builder::ProverProgress> Builder< /// paths for previous Sapling notes. pub fn add_sapling_spend( &mut self, - extsk: &sapling::zip32::ExtendedSpendingKey, + fvk: sapling::keys::FullViewingKey, note: Note, merkle_path: sapling::MerklePath, ) -> Result<(), Error> { if let Some(builder) = self.sapling_builder.as_mut() { - builder.add_spend(extsk, note, merkle_path)?; - - self.sapling_asks.push(extsk.expsk.ask.clone()); + builder.add_spend(fvk, note, merkle_path)?; Ok(()) } else { Err(Error::SaplingBuilderNotAvailable) @@ -518,11 +503,11 @@ impl<'a, P: consensus::Parameters, U: sapling::builder::ProverProgress> Builder< #[cfg(feature = "transparent-inputs")] pub fn add_transparent_input( &mut self, - sk: secp256k1::SecretKey, + pubkey: secp256k1::PublicKey, utxo: transparent::OutPoint, coin: TxOut, ) -> Result<(), transparent::builder::Error> { - self.transparent_builder.add_input(sk, utxo, coin) + self.transparent_builder.add_input(pubkey, utxo, coin) } /// Adds a transparent address to send funds to. @@ -660,15 +645,27 @@ impl<'a, P: consensus::Parameters, U: sapling::builder::ProverProgress> Builder< /// /// Upon success, returns a tuple containing the final transaction, and the /// [`SaplingMetadata`] generated during the build process. + #[allow(clippy::too_many_arguments)] pub fn build( self, + transparent_signing_set: &TransparentSigningSet, + sapling_extsks: &[sapling::zip32::ExtendedSpendingKey], + orchard_saks: &[orchard::keys::SpendAuthorizingKey], rng: R, spend_prover: &SP, output_prover: &OP, fee_rule: &FR, ) -> Result> { let fee = self.get_fee(fee_rule).map_err(Error::Fee)?; - self.build_internal(rng, spend_prover, output_prover, fee) + self.build_internal( + transparent_signing_set, + sapling_extsks, + orchard_saks, + rng, + spend_prover, + output_prover, + fee, + ) } /// Builds a transaction from the configured spends and outputs. @@ -683,17 +680,32 @@ impl<'a, P: consensus::Parameters, U: sapling::builder::ProverProgress> Builder< FR: FutureFeeRule, >( self, + transparent_signing_set: &TransparentSigningSet, + sapling_extsks: &[sapling::zip32::ExtendedSpendingKey], + orchard_saks: &[orchard::keys::SpendAuthorizingKey], rng: R, spend_prover: &SP, output_prover: &OP, fee_rule: &FR, ) -> Result> { let fee = self.get_fee_zfuture(fee_rule).map_err(Error::Fee)?; - self.build_internal(rng, spend_prover, output_prover, fee) + self.build_internal( + transparent_signing_set, + sapling_extsks, + orchard_saks, + rng, + spend_prover, + output_prover, + fee, + ) } + #[allow(clippy::too_many_arguments)] fn build_internal( self, + transparent_signing_set: &TransparentSigningSet, + sapling_extsks: &[sapling::zip32::ExtendedSpendingKey], + orchard_saks: &[orchard::keys::SpendAuthorizingKey], mut rng: R, spend_prover: &SP, output_prover: &OP, @@ -728,7 +740,7 @@ impl<'a, P: consensus::Parameters, U: sapling::builder::ProverProgress> Builder< .sapling_builder .and_then(|builder| { builder - .build::(&mut rng) + .build::(sapling_extsks, &mut rng) .map_err(Error::SaplingBuild) .transpose() .map(|res| { @@ -789,14 +801,20 @@ impl<'a, P: consensus::Parameters, U: sapling::builder::ProverProgress> Builder< // let txid_parts = unauthed_tx.digest(TxIdDigester); - let transparent_bundle = unauthed_tx.transparent_bundle.clone().map(|b| { - b.apply_signatures( - #[cfg(feature = "transparent-inputs")] - &unauthed_tx, - #[cfg(feature = "transparent-inputs")] - &txid_parts, - ) - }); + let transparent_bundle = unauthed_tx + .transparent_bundle + .clone() + .map(|b| { + b.apply_signatures( + #[cfg(feature = "transparent-inputs")] + &unauthed_tx, + #[cfg(feature = "transparent-inputs")] + &txid_parts, + transparent_signing_set, + ) + }) + .transpose() + .map_err(Error::TransparentBuild)?; #[cfg(zcash_unstable = "zfuture")] let tze_bundle = unauthed_tx @@ -812,15 +830,13 @@ impl<'a, P: consensus::Parameters, U: sapling::builder::ProverProgress> Builder< let shielded_sig_commitment = signature_hash(&unauthed_tx, &SignableInput::Shielded, &txid_parts); + let sapling_asks = sapling_extsks + .iter() + .map(|extsk| extsk.expsk.ask.clone()) + .collect::>(); let sapling_bundle = unauthed_tx .sapling_bundle - .map(|b| { - b.apply_signatures( - &mut rng, - *shielded_sig_commitment.as_ref(), - &self.sapling_asks, - ) - }) + .map(|b| b.apply_signatures(&mut rng, *shielded_sig_commitment.as_ref(), &sapling_asks)) .transpose() .map_err(Error::SaplingBuild)?; @@ -832,7 +848,7 @@ impl<'a, P: consensus::Parameters, U: sapling::builder::ProverProgress> Builder< b.apply_signatures( &mut rng, *shielded_sig_commitment.as_ref(), - &self.orchard_saks, + orchard_saks, ) }) }) @@ -987,7 +1003,7 @@ mod testing { self, prover::mock::{MockOutputProver, MockSpendProver}, }, - transaction::fees::zip317, + transaction::{components::transparent::builder::TransparentSigningSet, fees::zip317}, }; impl<'a, P: consensus::Parameters, U: sapling::builder::ProverProgress> Builder<'a, P, U> { @@ -995,6 +1011,9 @@ mod testing { /// DO NOT USE EXCEPT FOR UNIT TESTING. pub fn mock_build( self, + transparent_signing_set: &TransparentSigningSet, + sapling_extsks: &[sapling::zip32::ExtendedSpendingKey], + orchard_saks: &[orchard::keys::SpendAuthorizingKey], rng: R, ) -> Result> { struct FakeCryptoRng(R); @@ -1018,6 +1037,9 @@ mod testing { } self.build( + transparent_signing_set, + sapling_extsks, + orchard_saks, FakeCryptoRng(rng), &MockSpendProver, &MockOutputProver, @@ -1044,7 +1066,10 @@ mod tests { sapling::{self, zip32::ExtendedSpendingKey, Node, Rseed}, transaction::{ builder::BuildConfig, - components::amount::{Amount, BalanceError, NonNegativeAmount}, + components::{ + amount::{Amount, BalanceError, NonNegativeAmount}, + transparent::builder::TransparentSigningSet, + }, }, }; @@ -1069,6 +1094,7 @@ mod tests { use crate::consensus::NetworkUpgrade; use crate::legacy::keys::NonHardenedChildIndex; use crate::transaction::builder::{self, TransparentBuilder}; + use crate::transaction::components::transparent::builder::TransparentSigningSet; let sapling_activation_height = TEST_NETWORK .activation_height(NetworkUpgrade::Sapling) @@ -1091,11 +1117,14 @@ mod tests { tze_builder: std::marker::PhantomData, progress_notifier: (), orchard_builder: None, - sapling_asks: vec![], - orchard_saks: Vec::new(), }; + let mut transparent_signing_set = TransparentSigningSet::new(); let tsk = AccountPrivKey::from_seed(&TEST_NETWORK, &[0u8; 32], AccountId::ZERO).unwrap(); + let sk = tsk + .derive_external_secret_key(NonHardenedChildIndex::ZERO) + .unwrap(); + let pubkey = transparent_signing_set.add_key(sk); let prev_coin = TxOut { value: NonNegativeAmount::const_from_u64(50000), script_pubkey: tsk @@ -1107,12 +1136,7 @@ mod tests { .script(), }; builder - .add_transparent_input( - tsk.derive_external_secret_key(NonHardenedChildIndex::ZERO) - .unwrap(), - OutPoint::fake(), - prev_coin, - ) + .add_transparent_input(pubkey, OutPoint::fake(), prev_coin) .unwrap(); // Create a tx with only t output. No binding_sig should be present @@ -1123,7 +1147,9 @@ mod tests { ) .unwrap(); - let res = builder.mock_build(OsRng).unwrap(); + let res = builder + .mock_build(&transparent_signing_set, &[], &[], OsRng) + .unwrap(); // No binding signature, because only t input and outputs assert!(res.transaction().sapling_bundle.is_none()); } @@ -1157,7 +1183,7 @@ mod tests { // Create a tx with a sapling spend. binding_sig should be present builder - .add_sapling_spend::(&extsk, note1, witness1.path().unwrap()) + .add_sapling_spend::(dfvk.fvk().clone(), note1, witness1.path().unwrap()) .unwrap(); builder @@ -1168,7 +1194,9 @@ mod tests { .unwrap(); // A binding signature (and bundle) is present because there is a Sapling spend. - let res = builder.mock_build(OsRng).unwrap(); + let res = builder + .mock_build(&TransparentSigningSet::new(), &[extsk], &[], OsRng) + .unwrap(); assert!(res.transaction().sapling_bundle().is_some()); } @@ -1193,7 +1221,7 @@ mod tests { }; let builder = Builder::new(TEST_NETWORK, tx_height, build_config); assert_matches!( - builder.mock_build(OsRng), + builder.mock_build(&TransparentSigningSet::new(), &[], &[], OsRng), Err(Error::InsufficientFunds(expected)) if expected == MINIMUM_FEE.into() ); } @@ -1202,6 +1230,8 @@ mod tests { let ovk = Some(dfvk.fvk().ovk); let to = dfvk.default_address().1; + let extsks = &[extsk]; + // Fail if there is only a Sapling output // 0.0005 z-ZEC out, 0.0001 t-ZEC fee { @@ -1219,7 +1249,7 @@ mod tests { ) .unwrap(); assert_matches!( - builder.mock_build(OsRng), + builder.mock_build(&TransparentSigningSet::new(), extsks, &[], OsRng), Err(Error::InsufficientFunds(expected)) if expected == (NonNegativeAmount::const_from_u64(50000) + MINIMUM_FEE).unwrap().into() ); @@ -1240,7 +1270,7 @@ mod tests { ) .unwrap(); assert_matches!( - builder.mock_build(OsRng), + builder.mock_build(&TransparentSigningSet::new(), extsks, &[], OsRng), Err(Error::InsufficientFunds(expected)) if expected == (NonNegativeAmount::const_from_u64(50000) + MINIMUM_FEE).unwrap().into() ); @@ -1264,7 +1294,11 @@ mod tests { }; let mut builder = Builder::new(TEST_NETWORK, tx_height, build_config); builder - .add_sapling_spend::(&extsk, note1.clone(), witness1.path().unwrap()) + .add_sapling_spend::( + dfvk.fvk().clone(), + note1.clone(), + witness1.path().unwrap(), + ) .unwrap(); builder .add_sapling_output::( @@ -1281,7 +1315,7 @@ mod tests { ) .unwrap(); assert_matches!( - builder.mock_build(OsRng), + builder.mock_build(&TransparentSigningSet::new(), extsks, &[], OsRng), Err(Error::InsufficientFunds(expected)) if expected == Amount::const_from_i64(1) ); } @@ -1304,10 +1338,18 @@ mod tests { }; let mut builder = Builder::new(TEST_NETWORK, tx_height, build_config); builder - .add_sapling_spend::(&extsk, note1, witness1.path().unwrap()) + .add_sapling_spend::( + dfvk.fvk().clone(), + note1, + witness1.path().unwrap(), + ) .unwrap(); builder - .add_sapling_spend::(&extsk, note2, witness2.path().unwrap()) + .add_sapling_spend::( + dfvk.fvk().clone(), + note2, + witness2.path().unwrap(), + ) .unwrap(); builder .add_sapling_output::( @@ -1323,7 +1365,9 @@ mod tests { NonNegativeAmount::const_from_u64(15000), ) .unwrap(); - let res = builder.mock_build(OsRng).unwrap(); + let res = builder + .mock_build(&TransparentSigningSet::new(), extsks, &[], OsRng) + .unwrap(); assert_eq!( res.transaction() .fee_paid(|_| Err(BalanceError::Overflow)) diff --git a/zcash_primitives/src/transaction/components/transparent/builder.rs b/zcash_primitives/src/transaction/components/transparent/builder.rs index f0841e5e04..b076627e55 100644 --- a/zcash_primitives/src/transaction/components/transparent/builder.rs +++ b/zcash_primitives/src/transaction/components/transparent/builder.rs @@ -30,6 +30,8 @@ use { pub enum Error { InvalidAddress, InvalidAmount, + /// A bundle could not be built because a required signing keys was missing. + MissingSigningKey, } impl fmt::Display for Error { @@ -37,15 +39,56 @@ impl fmt::Display for Error { match self { Error::InvalidAddress => write!(f, "Invalid address"), Error::InvalidAmount => write!(f, "Invalid amount"), + Error::MissingSigningKey => write!(f, "Missing signing key"), } } } +/// A set of transparent signing keys. +/// +/// When the `transparent-inputs` feature flag is enabled, transparent signing keys can be +/// stored in this set and used to authorize transactions with transparent inputs. +pub struct TransparentSigningSet { + #[cfg(feature = "transparent-inputs")] + secp: secp256k1::Secp256k1, + #[cfg(feature = "transparent-inputs")] + keys: Vec<(secp256k1::SecretKey, secp256k1::PublicKey)>, +} + +impl Default for TransparentSigningSet { + fn default() -> Self { + Self::new() + } +} + +impl TransparentSigningSet { + /// Constructs an empty set of signing keys. + pub fn new() -> Self { + Self { + #[cfg(feature = "transparent-inputs")] + secp: secp256k1::Secp256k1::gen_new(), + #[cfg(feature = "transparent-inputs")] + keys: vec![], + } + } + + /// Adds a signing key to the set. + /// + /// Returns the corresponding pubkey. + #[cfg(feature = "transparent-inputs")] + pub fn add_key(&mut self, sk: secp256k1::SecretKey) -> secp256k1::PublicKey { + let pubkey = secp256k1::PublicKey::from_secret_key(&self.secp, &sk); + // Cache the pubkey for ease of matching later. + self.keys.push((sk, pubkey)); + pubkey + } +} + +// TODO: This feature gate can be removed. #[cfg(feature = "transparent-inputs")] #[derive(Debug, Clone)] pub struct TransparentInputInfo { - sk: secp256k1::SecretKey, - pubkey: [u8; secp256k1::constants::PUBLIC_KEY_SIZE], + pubkey: secp256k1::PublicKey, utxo: OutPoint, coin: TxOut, } @@ -62,8 +105,6 @@ impl TransparentInputInfo { } pub struct TransparentBuilder { - #[cfg(feature = "transparent-inputs")] - secp: secp256k1::Secp256k1, #[cfg(feature = "transparent-inputs")] inputs: Vec, vout: Vec, @@ -71,8 +112,6 @@ pub struct TransparentBuilder { #[derive(Debug, Clone)] pub struct Unauthorized { - #[cfg(feature = "transparent-inputs")] - secp: secp256k1::Secp256k1, #[cfg(feature = "transparent-inputs")] inputs: Vec, } @@ -85,8 +124,6 @@ impl TransparentBuilder { /// Constructs a new TransparentBuilder pub fn empty() -> Self { TransparentBuilder { - #[cfg(feature = "transparent-inputs")] - secp: secp256k1::Secp256k1::gen_new(), #[cfg(feature = "transparent-inputs")] inputs: vec![], vout: vec![], @@ -109,32 +146,27 @@ impl TransparentBuilder { #[cfg(feature = "transparent-inputs")] pub fn add_input( &mut self, - sk: secp256k1::SecretKey, + pubkey: secp256k1::PublicKey, utxo: OutPoint, coin: TxOut, ) -> Result<(), Error> { // Ensure that the RIPEMD-160 digest of the public key associated with the // provided secret key matches that of the address to which the provided // output may be spent. - let pubkey = secp256k1::PublicKey::from_secret_key(&self.secp, &sk).serialize(); match coin.script_pubkey.address() { Some(TransparentAddress::PublicKeyHash(hash)) => { use ripemd::Ripemd160; use sha2::Sha256; - if hash[..] != Ripemd160::digest(Sha256::digest(pubkey))[..] { + if hash[..] != Ripemd160::digest(Sha256::digest(pubkey.serialize()))[..] { return Err(Error::InvalidAddress); } } _ => return Err(Error::InvalidAddress), } - self.inputs.push(TransparentInputInfo { - sk, - pubkey, - utxo, - coin, - }); + self.inputs + .push(TransparentInputInfo { pubkey, utxo, coin }); Ok(()) } @@ -192,8 +224,6 @@ impl TransparentBuilder { vin, vout: self.vout, authorization: Unauthorized { - #[cfg(feature = "transparent-inputs")] - secp: self.secp, #[cfg(feature = "transparent-inputs")] inputs: self.inputs, }, @@ -296,7 +326,8 @@ impl Bundle { self, #[cfg(feature = "transparent-inputs")] mtx: &TransactionData, #[cfg(feature = "transparent-inputs")] txid_parts_cache: &TxDigests, - ) -> Bundle { + signing_set: &TransparentSigningSet, + ) -> Result, Error> { #[cfg(feature = "transparent-inputs")] let script_sigs = self .authorization @@ -304,6 +335,13 @@ impl Bundle { .iter() .enumerate() .map(|(index, info)| { + // Find the matching signing key. + let (sk, _) = signing_set + .keys + .iter() + .find(|(_, pubkey)| pubkey == &info.pubkey) + .ok_or(Error::MissingSigningKey)?; + let sighash = signature_hash( mtx, &SignableInput::Transparent { @@ -317,32 +355,34 @@ impl Bundle { ); let msg = secp256k1::Message::from_slice(sighash.as_ref()).expect("32 bytes"); - let sig = self.authorization.secp.sign_ecdsa(&msg, &info.sk); + let sig = signing_set.secp.sign_ecdsa(&msg, sk); // Signature has to have "SIGHASH_ALL" appended to it let mut sig_bytes: Vec = sig.serialize_der()[..].to_vec(); sig_bytes.extend([SIGHASH_ALL]); // P2PKH scriptSig - Script::default() << &sig_bytes[..] << &info.pubkey[..] + Ok(Script::default() << &sig_bytes[..] << &info.pubkey.serialize()[..]) }); #[cfg(not(feature = "transparent-inputs"))] - let script_sigs = std::iter::empty::