From beeea7b44e1218ab96c0d88239f9dac7e0fca116 Mon Sep 17 00:00:00 2001 From: Kris Nuttycombe Date: Mon, 12 Feb 2024 16:34:44 -0700 Subject: [PATCH] zcash_client_backend: Modify `Proposal` to make multi-step transactions representable. --- Cargo.lock | 1 + zcash_client_backend/CHANGELOG.md | 69 +-- zcash_client_backend/proto/proposal.proto | 80 +++- zcash_client_backend/src/data_api/error.rs | 10 + zcash_client_backend/src/data_api/wallet.rs | 123 +++++- .../src/data_api/wallet/input_selection.rs | 14 +- zcash_client_backend/src/proposal.rs | 223 ++++++++-- zcash_client_backend/src/proto.rs | 411 +++++++++++------- zcash_client_backend/src/proto/proposal.rs | 109 +++-- zcash_client_backend/src/wallet.rs | 1 + zcash_client_sqlite/Cargo.toml | 1 + zcash_client_sqlite/src/testing.rs | 17 +- zcash_client_sqlite/src/wallet.rs | 2 +- zcash_client_sqlite/src/wallet/sapling.rs | 32 +- 14 files changed, 781 insertions(+), 312 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3dd3a05c3..797c53e94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3062,6 +3062,7 @@ dependencies = [ "incrementalmerkletree", "jubjub", "maybe-rayon", + "nonempty", "orchard", "proptest", "prost", diff --git a/zcash_client_backend/CHANGELOG.md b/zcash_client_backend/CHANGELOG.md index 44e28b652..6ee5ff842 100644 --- a/zcash_client_backend/CHANGELOG.md +++ b/zcash_client_backend/CHANGELOG.md @@ -47,6 +47,7 @@ and this library adheres to Rust's notion of }` - `WalletSummary::next_sapling_subtree_index` - `wallet::propose_standard_transfer_to_address` + - `wallet::create_proposed_transactions` - `wallet::input_selection::ShieldedInputs` - `wallet::input_selection::ShieldingSelector` has been factored out from the `InputSelector` trait to separate out transparent @@ -60,9 +61,10 @@ and this library adheres to Rust's notion of - `wallet::TransparentAddressMetadata` (which replaces `zcash_keys::address::AddressMetadata`). - `zcash_client_backend::zip321::TransactionRequest::total` - `zcash_client_backend::zip321::parse::Param::name` -- `zcash_client_backend::proposal` - - `Proposal::{from_parts, shielded_inputs, payment_pools}` -- `zcash_client_backend::proto::` +- `zcash_client_backend::proposal`: + - `Proposal::{shielded_inputs, payment_pools, single_step, multi_step}` + - `Step` +- `zcash_client_backend::proto`: - `PROPOSAL_SER_V1` - `ProposalDecodingError` - `proposal` module, for parsing and serializing transaction proposals. @@ -76,8 +78,8 @@ and this library adheres to Rust's notion of wallet::{ReceivedSaplingNote, WalletTransparentOutput}, proposal::{Proposal, SaplingInputs}, }` -- `zcash_client_backend::zip321 - ` `TransactionRequest::{total, from_indexed}` +- `zcash_client_backend::zip321`: + - `TransactionRequest::{total, from_indexed}` - `parse::Param::name` ### Moved @@ -117,32 +119,44 @@ and this library adheres to Rust's notion of - The `NoteMismatch` variant now wraps a `NoteId` instead of a backend-specific note identifier. The related `NoteRef` type parameter has been removed from `error::Error`. - - A new variant `UnsupportedPoolType` has been added. - - A new variant `NoSupportedReceivers` has been added. - - A new variant `NoSpendingKey` has been added. - - A new variant `Proposal` has been added. + - New variants have been added: + - `Error::UnsupportedPoolType` + - `Error::NoSupportedReceivers` + - `Error::NoSpendingKey` + - `Error::Proposal` + - `Error::ProposalNotSupported` - Variant `ChildIndexOutOfRange` has been removed. - `wallet::shield_transparent_funds` no longer takes a `memo` argument; instead, memos to be associated with the shielded outputs should be specified in the construction of the value of the `input_selector` argument, which is used to construct the proposed shielded values as - internal "change" outputs. - - `wallet::create_proposed_transaction` no longer takes a - `change_memo` argument; instead, change memos are represented in the - individual values of the `proposed_change` field of the `Proposal`'s - `TransactionBalance`. - - `wallet::create_proposed_transaction` now takes its `proposal` argument - by reference instead of as an owned value. - - `wallet::create_proposed_transaction` no longer takes a `min_confirmations` - argument. Instead, it uses the anchor height from its `proposal` argument. - - `wallet::create_spend_to_address` now takes an additional - `change_memo` argument. + internal "change" outputs. Also, it returns its result as a `NonEmpty` + instead of a single `TxId`. + - `wallet::create_proposed_transaction` has been replaced by + `wallet::create_proposed_transactions`. Relative to the prior method, + the new method has the following changes: + - It no longer takes a `change_memo` argument; instead, change memos are + represented in the individual values of the `proposed_change` field of + the `Proposal`'s `TransactionBalance`. + - `wallet::create_proposed_transactions` takes its `proposal` argument + by reference instead of as an owned value. + - `wallet::create_proposed_transactions` no longer takes a `min_confirmations` + argument. Instead, it uses the anchor height from its `proposal` argument. + - `wallet::create_proposed_transactions` forces implementations to ignore + the database identifiers for its contained notes by universally quantifying + the `NoteRef` type parameter. + - It returns a `NonEmpty` instead of a single `TxId` value. + - `wallet::create_spend_to_address` now takes an additional `change_memo` + argument. It also returns its result as a `NonEmpty` instead of a + single `TxId`. + - `wallet::spend` returns its result as a `NonEmpty` instead of a + single `TxId`. - The error type of `wallet::create_spend_to_address` has been changed to use `zcash_primitives::transaction::fees::zip317::FeeError` instead of `zcash_primitives::transaction::components::amount::BalanceError`. - The following methods now take `&impl SpendProver, &impl OutputProver` instead of `impl TxProver`: - - `wallet::create_proposed_transaction` + - `wallet::create_proposed_transactions` - `wallet::create_spend_to_address` - `wallet::shield_transparent_funds` - `wallet::spend` @@ -162,8 +176,8 @@ and this library adheres to Rust's notion of `data_api::InputSource` must expose. - Changes to the `WalletRead` trait: - `get_checkpoint_depth` has been removed without replacement. This - is no longer needed given the change to use the stored anchor height for transaction - proposal execution. + is no longer needed given the change to use the stored anchor height for + transaction proposal execution. - `is_valid_account_extfvk` has been removed; it was unused in the ECC mobile wallet SDKs and has been superseded by `get_account_for_ufvk`. - `get_spendable_sapling_notes`, `select_spendable_sapling_notes`, and @@ -179,9 +193,6 @@ and this library adheres to Rust's notion of - `wallet::{propose_shielding, shield_transparent_funds}` now takes their `min_confirmations` arguments as `u32` rather than a `NonZeroU32` to permit implmentations to enable zero-conf shielding. - - `wallet::create_proposed_transaction` now forces implementations to ignore - the database identifiers for its contained notes by universally quantifying - the `NoteRef` type parameter. - `wallet::input_selection::GreedyInputSelector` now has relaxed requirements for its `InputSource` associated type. @@ -190,6 +201,11 @@ and this library adheres to Rust's notion of - `Proposal::min_anchor_height` has been removed in favor of storing this value in `SaplingInputs`. - `Proposal::sapling_inputs` has been replaced by `Proposal::shielded_inputs` + - In addition to having been moved to the `zcash_client_backend::proposal` + module, the `Proposal` type has been substantially modified in order to make + it possible to represent multi-step transactions, such as a deshielding + transaction followed by a zero-conf transfer as required by ZIP 320. Individual + transaction proposals are now represented by the `proposal::Step` type. - `zcash_client_backend::fees`: - `ChangeStrategy::compute_balance` arguments have changed. @@ -245,6 +261,7 @@ and this library adheres to Rust's notion of - `zcash_client_backend::data_api::ScannedBlock::from_parts` has been made crate-private. - `zcash_client_backend::data_api::ScannedBlock::into_sapling_commitments` has been replaced by `into_commitments` which returns a `ScannedBlockCommitments` value. +- `zcash_client_backend::data_api::wallet::create_proposed_transaction` ## [0.10.0] - 2023-09-25 diff --git a/zcash_client_backend/proto/proposal.proto b/zcash_client_backend/proto/proposal.proto index 533563dc5..a0b3091e0 100644 --- a/zcash_client_backend/proto/proposal.proto +++ b/zcash_client_backend/proto/proposal.proto @@ -5,34 +5,41 @@ syntax = "proto3"; package cash.z.wallet.sdk.ffi; -// A data structure that describes the inputs to be consumed and outputs to -// be produced in a proposed transaction. +// A data structure that describes the a series of transactions to be created. message Proposal { + // The version of this serialization format. uint32 protoVersion = 1; - // ZIP 321 serialized transaction request - string transactionRequest = 2; - // The vector of selected payment index / output pool mappings. Payment index - // 0 corresponds to the payment with no explicit index. - repeated PaymentOutputPool paymentOutputPools = 3; - // The anchor height to be used in creating the transaction, if any. - // Setting the anchor height to zero will disallow the use of any shielded - // inputs. - uint32 anchorHeight = 4; - // The inputs to be used in creating the transaction. - repeated ProposedInput inputs = 5; - // The total value, fee value, and change outputs of the proposed - // transaction - TransactionBalance balance = 6; // The fee rule used in constructing this proposal - FeeRule feeRule = 7; + FeeRule feeRule = 2; // The target height for which the proposal was constructed // // The chain must contain at least this many blocks in order for the proposal to // be executed. - uint32 minTargetHeight = 8; - // A flag indicating whether the proposal is for a shielding transaction, + uint32 minTargetHeight = 3; + // The series of transactions to be created. + repeated ProposalStep steps = 4; +} + +// A data structure that describes the inputs to be consumed and outputs to +// be produced in a proposed transaction. +message ProposalStep { + // ZIP 321 serialized transaction request + string transactionRequest = 1; + // The vector of selected payment index / output pool mappings. Payment index + // 0 corresponds to the payment with no explicit index. + repeated PaymentOutputPool paymentOutputPools = 2; + // The anchor height to be used in creating the transaction, if any. + // Setting the anchor height to zero will disallow the use of any shielded + // inputs. + uint32 anchorHeight = 3; + // The inputs to be used in creating the transaction. + repeated ProposedInput inputs = 4; + // The total value, fee value, and change outputs of the proposed + // transaction + TransactionBalance balance = 5; + // A flag indicating whether the step is for a shielding transaction, // used for determining which OVK to select for wallet-internal outputs. - bool isShielding = 9; + bool isShielding = 6; } enum ValuePool { @@ -57,14 +64,37 @@ message PaymentOutputPool { ValuePool valuePool = 2; } -// The unique identifier and value for each proposed input. -message ProposedInput { +// The unique identifier and value for each proposed input that does not +// require a back-reference to a prior step of the proposal. +message ReceivedOutput { bytes txid = 1; ValuePool valuePool = 2; uint32 index = 3; uint64 value = 4; } +// A reference a payment in a prior step of the proposal. This payment must +// belong to the wallet. +message PriorStepOutput { + uint32 stepIndex = 1; + uint32 paymentIndex = 2; +} + +// A reference a change output from a prior step of the proposal. +message PriorStepChange { + uint32 stepIndex = 1; + uint32 changeIndex = 2; +} + +// The unique identifier and value for an input to be used in the transaction. +message ProposedInput { + oneof value { + ReceivedOutput receivedOutput = 1; + PriorStepOutput priorStepOutput = 2; + PriorStepChange priorStepChange = 3; + } +} + // The fee rule used in constructing a Proposal enum FeeRule { // Protobuf requires that enums have a zero discriminant as the default @@ -82,15 +112,21 @@ enum FeeRule { // The proposed change outputs and fee value. message TransactionBalance { + // A list of change output values. repeated ChangeValue proposedChange = 1; + // The fee to be paid by the proposed transaction, in zatoshis. uint64 feeRequired = 2; } // A proposed change output. If the transparent value pool is selected, // the `memo` field must be null. message ChangeValue { + // The value of a change output to be created, in zatoshis. uint64 value = 1; + // The value pool in which the change output should be created. ValuePool valuePool = 2; + // The optional memo that should be associated with the newly created change output. + // Memos must not be present for transparent change outputs. MemoBytes memo = 3; } diff --git a/zcash_client_backend/src/data_api/error.rs b/zcash_client_backend/src/data_api/error.rs index e708afee2..31b76d7b9 100644 --- a/zcash_client_backend/src/data_api/error.rs +++ b/zcash_client_backend/src/data_api/error.rs @@ -37,6 +37,10 @@ pub enum Error { /// An error in transaction proposal construction Proposal(ProposalError), + /// The proposal was structurally valid, but spending shielded outputs of prior multi-step + /// transaction steps is not yet supported. + ProposalNotSupported, + /// No account could be found corresponding to a provided spending key. KeyNotRecognized, @@ -107,6 +111,12 @@ where Error::Proposal(e) => { write!(f, "Input selection attempted to construct an invalid proposal: {}", e) } + Error::ProposalNotSupported => { + write!( + f, + "The proposal was valid, but spending shielded outputs of prior transaction steps is not yet supported." + ) + } Error::KeyNotRecognized => { write!( f, diff --git a/zcash_client_backend/src/data_api/wallet.rs b/zcash_client_backend/src/data_api/wallet.rs index 5a97fa79c..02c613f1e 100644 --- a/zcash_client_backend/src/data_api/wallet.rs +++ b/zcash_client_backend/src/data_api/wallet.rs @@ -1,15 +1,16 @@ use std::num::NonZeroU32; +use nonempty::NonEmpty; use rand_core::OsRng; use sapling::{ note_encryption::{try_sapling_note_decryption, PreparedIncomingViewingKey}, prover::{OutputProver, SpendProver}, }; use zcash_primitives::{ - consensus::{self, NetworkUpgrade}, + consensus::{self, BlockHeight, NetworkUpgrade}, memo::MemoBytes, transaction::{ - builder::{BuildConfig, Builder}, + builder::{BuildConfig, BuildResult, Builder}, components::amount::{Amount, NonNegativeAmount}, fees::{zip317::FeeError as Zip317FeeError, FeeRule, StandardFeeRule}, Transaction, TxId, @@ -26,7 +27,7 @@ use crate::{ decrypt_transaction, fees::{self, DustOutputPolicy}, keys::UnifiedSpendingKey, - proposal::Proposal, + proposal::{self, Proposal}, wallet::{Note, OvkPolicy, Recipient}, zip321::{self, Payment}, PoolType, ShieldedProtocol, @@ -207,7 +208,7 @@ pub fn create_spend_to_address( min_confirmations: NonZeroU32, change_memo: Option, ) -> Result< - TxId, + NonEmpty, Error< ::Error, ::Error, @@ -238,7 +239,7 @@ where change_memo, )?; - create_proposed_transaction( + create_proposed_transactions( wallet_db, params, spend_prover, @@ -316,7 +317,7 @@ pub fn spend( ovk_policy: OvkPolicy, min_confirmations: NonZeroU32, ) -> Result< - TxId, + NonEmpty, Error< ::Error, ::Error, @@ -344,7 +345,7 @@ where min_confirmations, )?; - create_proposed_transaction( + create_proposed_transactions( wallet_db, params, spend_prover, @@ -521,7 +522,7 @@ where /// to fall back to the transparent receiver until full Orchard support is implemented. #[allow(clippy::too_many_arguments)] #[allow(clippy::type_complexity)] -pub fn create_proposed_transaction( +pub fn create_proposed_transactions( wallet_db: &mut DbT, params: &ParamsT, spend_prover: &impl SpendProver, @@ -530,7 +531,7 @@ pub fn create_proposed_transaction( ovk_policy: OvkPolicy, proposal: &Proposal, ) -> Result< - TxId, + NonEmpty, Error< ::Error, ::Error, @@ -543,6 +544,83 @@ where ParamsT: consensus::Parameters + Clone, FeeRuleT: FeeRule, { + let mut step_results = Vec::with_capacity(proposal.steps().len()); + for step in proposal.steps() { + let step_result = create_proposed_transaction( + wallet_db, + params, + spend_prover, + output_prover, + usk, + ovk_policy.clone(), + proposal.fee_rule(), + proposal.min_target_height(), + &step_results, + step, + )?; + step_results.push((step, step_result)); + } + + Ok(NonEmpty::from_vec( + step_results + .iter() + .map(|(_, r)| r.transaction().txid()) + .collect(), + ) + .expect("proposal.steps is NonEmpty")) +} + +#[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, + ovk_policy: OvkPolicy, + fee_rule: &FeeRuleT, + min_target_height: BlockHeight, + prior_step_results: &[(&proposal::Step, BuildResult)], + proposal_step: &proposal::Step, +) -> Result< + BuildResult, + Error< + ::Error, + ::Error, + InputsErrT, + FeeRuleT::Error, + >, +> +where + DbT: WalletWrite + WalletCommitmentTrees, + ParamsT: consensus::Parameters + Clone, + FeeRuleT: FeeRule, +{ + // TODO: Spending shielded outputs of prior multi-step transaction steps is not yet + // supported. Maybe support this at some point? Doing so would require a higher-level + // approach in the wallet that waits for transactions with shielded outputs to be + // mined and only then attempts to perform the next step. + if proposal_step.prior_step_inputs().iter().any(|s_ref| { + prior_step_results.len() <= s_ref.step_index() + || match s_ref.output_index() { + proposal::StepOutputIndex::Payment(i) => prior_step_results[s_ref.step_index()] + .0 + .payment_pools() + .get(&i) + .iter() + .all(|pool| matches!(pool, PoolType::Shielded(_))), + + proposal::StepOutputIndex::Change(_) => { + // Only shielded change is supported by zcash_client_backend, so multi-step + // transactions cannot yet spend prior transactions' change outputs. + true + } + } + }) { + return Err(Error::ProposalNotSupported); + } + let account = wallet_db .get_account_for_ufvk(&usk.to_unified_full_viewing_key()) .map_err(Error::DataSource)? @@ -559,7 +637,7 @@ where let internal_ovk = || { #[cfg(feature = "transparent-inputs")] - return if proposal.is_shielding() { + return if proposal_step.is_shielding() { Some(OutgoingViewingKey( usk.transparent() .to_account_pubkey() @@ -574,7 +652,7 @@ where Some(dfvk.to_ovk(Scope::Internal)) }; - let (sapling_anchor, sapling_inputs) = proposal.shielded_inputs().map_or_else( + let (sapling_anchor, sapling_inputs) = proposal_step.shielded_inputs().map_or_else( || Ok((sapling::Anchor::empty_tree(), vec![])), |inputs| { wallet_db.with_sapling_tree_mut::<_, _, Error<_, _, _, _>>(|sapling_tree| { @@ -619,7 +697,7 @@ where // are no possible transparent inputs, so we ignore those let mut builder = Builder::new( params.clone(), - proposal.min_target_height(), + min_target_height, BuildConfig::Standard { sapling_anchor: Some(sapling_anchor), orchard_anchor: None, @@ -637,7 +715,7 @@ where .map_err(Error::DataSource)?; let mut utxos: Vec = vec![]; - for utxo in proposal.transparent_inputs() { + for utxo in proposal_step.transparent_inputs() { utxos.push(utxo.clone()); let address_metadata = known_addrs @@ -662,7 +740,7 @@ where let mut sapling_output_meta = vec![]; let mut transparent_output_meta = vec![]; - for payment in proposal.transaction_request().payments().values() { + for payment in proposal_step.transaction_request().payments().values() { match &payment.recipient_address { Address::Unified(ua) => { let memo = payment @@ -716,7 +794,7 @@ where } } - for change_value in proposal.balance().proposed_change() { + for change_value in proposal_step.balance().proposed_change() { let memo = change_value .memo() .map_or_else(MemoBytes::empty, |m| m.clone()); @@ -751,7 +829,7 @@ where } // Build the transaction with the specified fee rule - let build_result = builder.build(OsRng, spend_prover, output_prover, proposal.fee_rule())?; + let build_result = builder.build(OsRng, spend_prover, output_prover, fee_rule)?; let internal_ivk = PreparedIncomingViewingKey::new(&dfvk.to_ivk(Scope::Internal)); let sapling_outputs = @@ -776,10 +854,7 @@ where try_sapling_note_decryption( &internal_ivk, &bundle.shielded_outputs()[output_index], - consensus::sapling_zip212_enforcement( - params, - proposal.min_target_height(), - ), + consensus::sapling_zip212_enforcement(params, min_target_height), ) .map(|(note, _, _)| (account, note)) }) @@ -819,13 +894,13 @@ where created: time::OffsetDateTime::now_utc(), account, outputs: sapling_outputs.chain(transparent_outputs).collect(), - fee_amount: Amount::from(proposal.balance().fee_required()), + fee_amount: Amount::from(proposal_step.balance().fee_required()), #[cfg(feature = "transparent-inputs")] utxos_spent: utxos.iter().map(|utxo| utxo.outpoint().clone()).collect(), }) .map_err(Error::DataSource)?; - Ok(build_result.transaction().txid()) + Ok(build_result) } /// Constructs a transaction that consumes available transparent UTXOs belonging to @@ -875,7 +950,7 @@ pub fn shield_transparent_funds( from_addrs: &[TransparentAddress], min_confirmations: u32, ) -> Result< - TxId, + NonEmpty, Error< ::Error, ::Error, @@ -897,7 +972,7 @@ where min_confirmations, )?; - create_proposed_transaction( + create_proposed_transactions( wallet_db, params, spend_prover, diff --git a/zcash_client_backend/src/data_api/wallet/input_selection.rs b/zcash_client_backend/src/data_api/wallet/input_selection.rs index 689ddc66b..c38cd970c 100644 --- a/zcash_client_backend/src/data_api/wallet/input_selection.rs +++ b/zcash_client_backend/src/data_api/wallet/input_selection.rs @@ -10,7 +10,6 @@ use std::{ use nonempty::NonEmpty; use zcash_primitives::{ consensus::{self, BlockHeight}, - legacy::TransparentAddress, transaction::{ components::{ amount::{BalanceError, NonNegativeAmount}, @@ -31,11 +30,12 @@ use crate::{ PoolType, ShieldedProtocol, }; -#[cfg(any(feature = "transparent-inputs"))] -use std::convert::Infallible; - #[cfg(feature = "transparent-inputs")] -use {std::collections::BTreeSet, zcash_primitives::transaction::components::OutPoint}; +use { + std::collections::BTreeSet, std::convert::Infallible, + zcash_primitives::legacy::TransparentAddress, + zcash_primitives::transaction::components::OutPoint, +}; #[cfg(feature = "orchard")] use crate::fees::orchard as orchard_fees; @@ -433,7 +433,7 @@ where match balance { Ok(balance) => { - return Proposal::from_parts( + return Proposal::single_step( transaction_request, payment_pools, vec![], @@ -582,7 +582,7 @@ where }; if balance.total() >= shielding_threshold { - Proposal::from_parts( + Proposal::single_step( TransactionRequest::empty(), BTreeMap::new(), transparent_inputs, diff --git a/zcash_client_backend/src/proposal.rs b/zcash_client_backend/src/proposal.rs index 500c866ed..70123bece 100644 --- a/zcash_client_backend/src/proposal.rs +++ b/zcash_client_backend/src/proposal.rs @@ -15,7 +15,7 @@ use crate::{ PoolType, }; -/// Errors that can occur in construction of a [`Proposal`]. +/// Errors that can occur in construction of a [`Step`]. #[derive(Debug, Clone)] pub enum ProposalError { /// The total output value of the transaction request is not a valid Zcash amount. @@ -35,6 +35,8 @@ pub enum ProposalError { /// * There provided transaction request is empty; i.e. the only output values specified /// are change and fee amounts. ShieldingInvalid, + /// A reference to the output of a prior step is invalid. + ReferenceError(StepOutput), } impl Display for ProposalError { @@ -61,6 +63,7 @@ impl Display for ProposalError { f, "The proposal violates the rules for a shielding transaction." ), + ProposalError::ReferenceError(r) => write!(f, "No prior step output found for {:?}", r), } } } @@ -98,21 +101,165 @@ impl ShieldedInputs { } } -/// The inputs to be consumed and outputs to be produced in a proposed transaction. +/// A proposal for a series of transactions to be created. +/// +/// Each step of the proposal represents a separate transaction to be created. At present, only +/// transparent outputs of earlier steps may be spent in later steps; the ability to chain shielded +/// transaction steps may be added in a future update. #[derive(Clone, PartialEq, Eq)] pub struct Proposal { + fee_rule: FeeRuleT, + min_target_height: BlockHeight, + steps: NonEmpty>, +} + +impl Proposal { + /// Constructs a validated multi-step [`Proposal`]. + /// + /// This operation validates the proposal for agreement between outputs and inputs + /// in the case of multi-step proposals, and ensures that no double-spends are being + /// proposed. + /// + /// Parameters: + /// * `fee_rule`: The fee rule observed by the proposed transaction. + /// * `min_target_height`: The minimum block height at which the transaction may be created. + /// * `steps`: A vector of steps that make up the proposal. + pub fn multi_step( + fee_rule: FeeRuleT, + min_target_height: BlockHeight, + steps: NonEmpty>, + ) -> Result { + // TODO: actually perform the validation described in the documentation. + + Ok(Self { + fee_rule, + min_target_height, + steps, + }) + } + + /// Constructs a validated [`Proposal`] having only a single step from its constituent parts. + /// + /// This operation validates the proposal for balance consistency and agreement between + /// the `is_shielding` flag and the structure of the proposal. + /// + /// Parameters: + /// * `transaction_request`: The ZIP 321 transaction request describing the payments to be + /// made. + /// * `payment_pools`: A map from payment index to pool type. + /// * `transparent_inputs`: The set of previous transparent outputs to be spent. + /// * `shielded_inputs`: The sets of previous shielded outputs to be spent. + /// * `balance`: The change outputs to be added the transaction and the fee to be paid. + /// * `fee_rule`: The fee rule observed by the proposed transaction. + /// * `min_target_height`: The minimum block height at which the transaction may be created. + /// * `is_shielding`: A flag that identifies whether this is a wallet-internal shielding + /// transaction. + #[allow(clippy::too_many_arguments)] + pub fn single_step( + transaction_request: TransactionRequest, + payment_pools: BTreeMap, + transparent_inputs: Vec, + shielded_inputs: Option>, + balance: TransactionBalance, + fee_rule: FeeRuleT, + min_target_height: BlockHeight, + is_shielding: bool, + ) -> Result { + Ok(Self { + fee_rule, + min_target_height, + steps: NonEmpty::singleton(Step::from_parts( + &[], + transaction_request, + payment_pools, + transparent_inputs, + shielded_inputs, + vec![], + balance, + is_shielding, + )?), + }) + } + + /// Returns the fee rule to be used by the transaction builder. + pub fn fee_rule(&self) -> &FeeRuleT { + &self.fee_rule + } + + /// Returns the target height for which the proposal was prepared. + /// + /// The chain must contain at least this many blocks in order for the proposal to + /// be executed. + pub fn min_target_height(&self) -> BlockHeight { + self.min_target_height + } + + /// Returns the steps of the proposal. Each step corresponds to an independent transaction to + /// be generated as a result of this proposal. + pub fn steps(&self) -> &NonEmpty> { + &self.steps + } +} + +impl Debug for Proposal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Proposal") + .field("fee_rule", &self.fee_rule) + .field("min_target_height", &self.min_target_height) + .field("steps", &self.steps) + .finish() + } +} + +/// A reference to either a payment or change output within a step. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepOutputIndex { + Payment(usize), + Change(usize), +} + +/// A reference to the output of a step in a proposal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StepOutput { + step_index: usize, + output_index: StepOutputIndex, +} + +impl StepOutput { + /// Constructs a new [`StepOutput`] from its constituent parts. + pub fn new(step_index: usize, output_index: StepOutputIndex) -> Self { + Self { + step_index, + output_index, + } + } + + /// Returns the step index to which this reference refers. + pub fn step_index(&self) -> usize { + self.step_index + } + + /// Returns the identifier for the payment or change output within + /// the referenced step. + pub fn output_index(&self) -> StepOutputIndex { + self.output_index + } +} + +/// The inputs to be consumed and outputs to be produced in a proposed transaction. +#[derive(Clone, PartialEq, Eq)] +pub struct Step { transaction_request: TransactionRequest, payment_pools: BTreeMap, transparent_inputs: Vec, shielded_inputs: Option>, + prior_step_inputs: Vec, balance: TransactionBalance, - fee_rule: FeeRuleT, - min_target_height: BlockHeight, is_shielding: bool, } -impl Proposal { - /// Constructs a validated [`Proposal`] from its constituent parts. +impl Step { + /// Constructs a validated [`Step`] from its constituent parts. /// /// This operation validates the proposal for balance consistency and agreement between /// the `is_shielding` flag and the structure of the proposal. @@ -124,19 +271,17 @@ impl Proposal { /// * `transparent_inputs`: The set of previous transparent outputs to be spent. /// * `shielded_inputs`: The sets of previous shielded outputs to be spent. /// * `balance`: The change outputs to be added the transaction and the fee to be paid. - /// * `fee_rule`: The fee rule observed by the proposed transaction. - /// * `min_target_height`: The minimum block height at which the transaction may be created. /// * `is_shielding`: A flag that identifies whether this is a wallet-internal shielding /// transaction. #[allow(clippy::too_many_arguments)] pub fn from_parts( + prior_steps: &[Step], transaction_request: TransactionRequest, payment_pools: BTreeMap, transparent_inputs: Vec, shielded_inputs: Option>, + prior_step_inputs: Vec, balance: TransactionBalance, - fee_rule: FeeRuleT, - min_target_height: BlockHeight, is_shielding: bool, ) -> Result { let transparent_input_total = transparent_inputs @@ -145,14 +290,43 @@ impl Proposal { .fold(Ok(NonNegativeAmount::ZERO), |acc, a| { (acc? + a).ok_or(ProposalError::Overflow) })?; + let shielded_input_total = shielded_inputs .iter() .flat_map(|s_in| s_in.notes().iter()) .map(|out| out.note().value()) .fold(Some(NonNegativeAmount::ZERO), |acc, a| (acc? + a)) .ok_or(ProposalError::Overflow)?; - let input_total = - (transparent_input_total + shielded_input_total).ok_or(ProposalError::Overflow)?; + + let prior_step_input_total = prior_step_inputs + .iter() + .map(|s_ref| { + let step = prior_steps + .get(s_ref.step_index) + .ok_or(ProposalError::ReferenceError(*s_ref))?; + Ok(match s_ref.output_index { + StepOutputIndex::Payment(i) => { + step.transaction_request + .payments() + .get(&i) + .ok_or(ProposalError::ReferenceError(*s_ref))? + .amount + } + StepOutputIndex::Change(i) => step + .balance + .proposed_change() + .get(i) + .ok_or(ProposalError::ReferenceError(*s_ref))? + .value(), + }) + }) + .collect::, _>>()? + .into_iter() + .fold(Some(NonNegativeAmount::ZERO), |acc, a| (acc? + a)) + .ok_or(ProposalError::Overflow)?; + + let input_total = (transparent_input_total + shielded_input_total + prior_step_input_total) + .ok_or(ProposalError::Overflow)?; let request_total = transaction_request .total() @@ -173,9 +347,8 @@ impl Proposal { payment_pools, transparent_inputs, shielded_inputs, + prior_step_inputs, balance, - fee_rule, - min_target_height, is_shielding, }) } else { @@ -203,21 +376,15 @@ impl Proposal { pub fn shielded_inputs(&self) -> Option<&ShieldedInputs> { self.shielded_inputs.as_ref() } + /// Returns the inputs that should be obtained from the outputs of the transaction + /// created to satisfy a previous step of the proposal. + pub fn prior_step_inputs(&self) -> &[StepOutput] { + self.prior_step_inputs.as_ref() + } /// Returns the change outputs to be added to the transaction and the fee to be paid. pub fn balance(&self) -> &TransactionBalance { &self.balance } - /// Returns the fee rule to be used by the transaction builder. - pub fn fee_rule(&self) -> &FeeRuleT { - &self.fee_rule - } - /// Returns the target height for which the proposal was prepared. - /// - /// The chain must contain at least this many blocks in order for the proposal to - /// be executed. - pub fn min_target_height(&self) -> BlockHeight { - self.min_target_height - } /// Returns a flag indicating whether or not the proposed transaction /// is exclusively wallet-internal (if it does not involve any external /// recipients). @@ -226,9 +393,9 @@ impl Proposal { } } -impl Debug for Proposal { +impl Debug for Step { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Proposal") + f.debug_struct("Step") .field("transaction_request", &self.transaction_request) .field("transparent_inputs", &self.transparent_inputs) .field( @@ -240,8 +407,6 @@ impl Debug for Proposal { &self.shielded_inputs().map(|i| i.anchor_height), ) .field("balance", &self.balance) - //.field("fee_rule", &self.fee_rule) - .field("min_target_height", &self.min_target_height) .field("is_shielding", &self.is_shielding) .finish_non_exhaustive() } diff --git a/zcash_client_backend/src/proto.rs b/zcash_client_backend/src/proto.rs index 4ea33c1f2..9e457c8c2 100644 --- a/zcash_client_backend/src/proto.rs +++ b/zcash_client_backend/src/proto.rs @@ -24,7 +24,7 @@ use zcash_note_encryption::{EphemeralKeyBytes, COMPACT_NOTE_SIZE}; use crate::{ data_api::InputSource, fees::{ChangeValue, TransactionBalance}, - proposal::{Proposal, ProposalError, ShieldedInputs}, + proposal::{Proposal, ProposalError, ShieldedInputs, Step, StepOutput, StepOutputIndex}, zip321::{TransactionRequest, Zip321Error}, PoolType, ShieldedProtocol, }; @@ -216,8 +216,12 @@ pub const PROPOSAL_SER_V1: u32 = 1; /// representation. #[derive(Debug, Clone)] pub enum ProposalDecodingError { + /// The encoded proposal contained no steps + NoSteps, /// The ZIP 321 transaction request URI was invalid. Zip321(Zip321Error), + /// A proposed input was null. + NullInput(usize), /// A transaction identifier string did not decode to a valid transaction ID. TxIdInvalid(TryFromSliceError), /// An invalid value pool identifier was encountered. @@ -252,7 +256,11 @@ impl From for ProposalDecodingError { impl Display for ProposalDecodingError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + ProposalDecodingError::NoSteps => write!(f, "The proposal had no steps."), ProposalDecodingError::Zip321(err) => write!(f, "Transaction request invalid: {}", err), + ProposalDecodingError::NullInput(i) => { + write!(f, "Proposed input was null at index {}", i) + } ProposalDecodingError::TxIdInvalid(err) => { write!(f, "Invalid transaction id: {:?}", err) } @@ -317,7 +325,7 @@ fn pool_type(pool_id: i32) -> Result> { } } -impl proposal::ProposedInput { +impl proposal::ReceivedOutput { pub fn parse_txid(&self) -> Result { Ok(TxId::from_bytes(self.txid[..].try_into()?)) } @@ -359,64 +367,112 @@ impl proposal::Proposal { params: &P, value: &Proposal, ) -> Self { - let transaction_request = value.transaction_request().to_uri(params); - - let anchor_height = value - .shielded_inputs() - .map_or_else(|| 0, |i| u32::from(i.anchor_height())); - - let inputs = value - .transparent_inputs() + use proposal::proposed_input; + use proposal::{PriorStepChange, PriorStepOutput, ReceivedOutput}; + let steps = value + .steps() .iter() - .map(|utxo| proposal::ProposedInput { - txid: utxo.outpoint().hash().to_vec(), - value_pool: proposal::ValuePool::Transparent.into(), - index: utxo.outpoint().n(), - value: utxo.txout().value.into(), - }) - .chain(value.shielded_inputs().iter().flat_map(|s_in| { - s_in.notes().iter().map(|rec_note| proposal::ProposedInput { - txid: rec_note.txid().as_ref().to_vec(), - value_pool: proposal::ValuePool::from(rec_note.note().protocol()).into(), - index: rec_note.output_index().into(), - value: rec_note.note().value().into(), - }) - })) - .collect(); + .map(|step| { + let transaction_request = step.transaction_request().to_uri(params); - let payment_output_pools = value - .payment_pools() - .iter() - .map(|(idx, pool_type)| proposal::PaymentOutputPool { - payment_index: u32::try_from(*idx).expect("Payment index fits into a u32"), - value_pool: proposal::ValuePool::from(*pool_type).into(), + let anchor_height = step + .shielded_inputs() + .map_or_else(|| 0, |i| u32::from(i.anchor_height())); + + let inputs = step + .transparent_inputs() + .iter() + .map(|utxo| proposal::ProposedInput { + value: Some(proposed_input::Value::ReceivedOutput(ReceivedOutput { + txid: utxo.outpoint().hash().to_vec(), + value_pool: proposal::ValuePool::Transparent.into(), + index: utxo.outpoint().n(), + value: utxo.txout().value.into(), + })), + }) + .chain(step.shielded_inputs().iter().flat_map(|s_in| { + s_in.notes().iter().map(|rec_note| proposal::ProposedInput { + value: Some(proposed_input::Value::ReceivedOutput(ReceivedOutput { + txid: rec_note.txid().as_ref().to_vec(), + value_pool: proposal::ValuePool::from(rec_note.note().protocol()) + .into(), + index: rec_note.output_index().into(), + value: rec_note.note().value().into(), + })), + }) + })) + .chain(step.prior_step_inputs().iter().map(|p_in| { + match p_in.output_index() { + StepOutputIndex::Payment(i) => proposal::ProposedInput { + value: Some(proposed_input::Value::PriorStepOutput( + PriorStepOutput { + step_index: p_in + .step_index() + .try_into() + .expect("Step index fits into a u32"), + payment_index: i + .try_into() + .expect("Payment index fits into a u32"), + }, + )), + }, + StepOutputIndex::Change(i) => proposal::ProposedInput { + value: Some(proposed_input::Value::PriorStepChange( + PriorStepChange { + step_index: p_in + .step_index() + .try_into() + .expect("Step index fits into a u32"), + change_index: i + .try_into() + .expect("Payment index fits into a u32"), + }, + )), + }, + } + })) + .collect(); + + let payment_output_pools = step + .payment_pools() + .iter() + .map(|(idx, pool_type)| proposal::PaymentOutputPool { + payment_index: u32::try_from(*idx).expect("Payment index fits into a u32"), + value_pool: proposal::ValuePool::from(*pool_type).into(), + }) + .collect(); + + let balance = Some(proposal::TransactionBalance { + proposed_change: step + .balance() + .proposed_change() + .iter() + .map(|change| proposal::ChangeValue { + value: change.value().into(), + value_pool: proposal::ValuePool::from(change.output_pool()).into(), + memo: change.memo().map(|memo_bytes| proposal::MemoBytes { + value: memo_bytes.as_slice().to_vec(), + }), + }) + .collect(), + fee_required: step.balance().fee_required().into(), + }); + + #[allow(deprecated)] + proposal::ProposalStep { + transaction_request, + payment_output_pools, + anchor_height, + inputs, + balance, + is_shielding: step.is_shielding(), + } }) .collect(); - let balance = Some(proposal::TransactionBalance { - proposed_change: value - .balance() - .proposed_change() - .iter() - .map(|change| proposal::ChangeValue { - value: change.value().into(), - value_pool: proposal::ValuePool::from(change.output_pool()).into(), - memo: change.memo().map(|memo_bytes| proposal::MemoBytes { - value: memo_bytes.as_slice().to_vec(), - }), - }) - .collect(), - fee_required: value.balance().fee_required().into(), - }); - #[allow(deprecated)] proposal::Proposal { proto_version: PROPOSAL_SER_V1, - transaction_request, - payment_output_pools, - anchor_height, - inputs, - balance, fee_rule: match value.fee_rule() { StandardFeeRule::PreZip313 => proposal::FeeRule::PreZip313, StandardFeeRule::Zip313 => proposal::FeeRule::Zip313, @@ -424,7 +480,7 @@ impl proposal::Proposal { } .into(), min_target_height: value.min_target_height().into(), - is_shielding: value.is_shielding(), + steps, } } @@ -438,6 +494,7 @@ impl proposal::Proposal { where DbT: InputSource, { + use self::proposal::proposed_input::Value::*; match self.proto_version { PROPOSAL_SER_V1 => { #[allow(deprecated)] @@ -450,116 +507,166 @@ impl proposal::Proposal { } }; - let transaction_request = - TransactionRequest::from_uri(params, &self.transaction_request)?; + let mut steps = Vec::with_capacity(self.steps.len()); + for step in &self.steps { + let transaction_request = + TransactionRequest::from_uri(params, &step.transaction_request)?; - let payment_pools = self - .payment_output_pools - .iter() - .map(|pop| { - Ok(( - usize::try_from(pop.payment_index) - .expect("Payment index fits into a usize"), - pool_type(pop.value_pool)?, - )) - }) - .collect::, ProposalDecodingError>>( - )?; + let payment_pools = step + .payment_output_pools + .iter() + .map(|pop| { + Ok(( + usize::try_from(pop.payment_index) + .expect("Payment index fits into a usize"), + pool_type(pop.value_pool)?, + )) + }) + .collect::, ProposalDecodingError>>()?; - #[cfg(not(feature = "transparent-inputs"))] - let transparent_inputs = vec![]; - #[cfg(feature = "transparent-inputs")] - let mut transparent_inputs = vec![]; + #[cfg(not(feature = "transparent-inputs"))] + let transparent_inputs = vec![]; + #[cfg(feature = "transparent-inputs")] + let mut transparent_inputs = vec![]; + let mut received_notes = vec![]; + let mut prior_step_inputs = vec![]; + for (i, input) in step.inputs.iter().enumerate() { + match input + .value + .as_ref() + .ok_or(ProposalDecodingError::NullInput(i))? + { + ReceivedOutput(out) => { + let txid = out + .parse_txid() + .map_err(ProposalDecodingError::TxIdInvalid)?; - let mut received_notes = vec![]; - for input in self.inputs.iter() { - let txid = input - .parse_txid() - .map_err(ProposalDecodingError::TxIdInvalid)?; + match out.pool_type()? { + PoolType::Transparent => { + #[cfg(not(feature = "transparent-inputs"))] + return Err(ProposalDecodingError::ValuePoolNotSupported( + 1, + )); - match input.pool_type()? { - PoolType::Transparent => { - #[cfg(not(feature = "transparent-inputs"))] - return Err(ProposalDecodingError::ValuePoolNotSupported(1)); - - #[cfg(feature = "transparent-inputs")] - { - let outpoint = OutPoint::new(txid.into(), input.index); - transparent_inputs.push( - wallet_db - .get_unspent_transparent_output(&outpoint) - .map_err(ProposalDecodingError::InputRetrieval)? - .ok_or({ - ProposalDecodingError::InputNotFound( - txid, - PoolType::Transparent, - input.index, - ) - })?, - ); + #[cfg(feature = "transparent-inputs")] + { + let outpoint = OutPoint::new(txid.into(), out.index); + transparent_inputs.push( + wallet_db + .get_unspent_transparent_output(&outpoint) + .map_err(ProposalDecodingError::InputRetrieval)? + .ok_or({ + ProposalDecodingError::InputNotFound( + txid, + PoolType::Transparent, + out.index, + ) + })?, + ); + } + } + PoolType::Shielded(protocol) => received_notes.push( + wallet_db + .get_spendable_note(&txid, protocol, out.index) + .map_err(ProposalDecodingError::InputRetrieval) + .and_then(|opt| { + opt.ok_or({ + ProposalDecodingError::InputNotFound( + txid, + PoolType::Shielded(protocol), + out.index, + ) + }) + })?, + ), + } + } + PriorStepOutput(s_ref) => { + prior_step_inputs.push(StepOutput::new( + s_ref + .step_index + .try_into() + .expect("Step index fits into a usize"), + StepOutputIndex::Payment( + s_ref + .payment_index + .try_into() + .expect("Payment index fits into a usize"), + ), + )); + } + PriorStepChange(s_ref) => { + prior_step_inputs.push(StepOutput::new( + s_ref + .step_index + .try_into() + .expect("Step index fits into a usize"), + StepOutputIndex::Change( + s_ref + .change_index + .try_into() + .expect("Payment index fits into a usize"), + ), + )); } } - PoolType::Shielded(protocol) => received_notes.push( - wallet_db - .get_spendable_note(&txid, protocol, input.index) - .map_err(ProposalDecodingError::InputRetrieval) - .and_then(|opt| { - opt.ok_or({ - ProposalDecodingError::InputNotFound( - txid, - PoolType::Shielded(protocol), - input.index, - ) - }) - })?, - ), } + + let shielded_inputs = NonEmpty::from_vec(received_notes) + .map(|notes| ShieldedInputs::from_parts(step.anchor_height.into(), notes)); + + let proto_balance = step + .balance + .as_ref() + .ok_or(ProposalDecodingError::BalanceInvalid)?; + let balance = TransactionBalance::new( + proto_balance + .proposed_change + .iter() + .map(|cv| -> Result> { + match cv.pool_type()? { + PoolType::Shielded(ShieldedProtocol::Sapling) => { + Ok(ChangeValue::sapling( + NonNegativeAmount::from_u64(cv.value).map_err( + |_| ProposalDecodingError::BalanceInvalid, + )?, + cv.memo + .as_ref() + .map(|bytes| { + MemoBytes::from_bytes(&bytes.value) + .map_err(ProposalDecodingError::MemoInvalid) + }) + .transpose()?, + )) + } + t => Err(ProposalDecodingError::InvalidChangeRecipient(t)), + } + }) + .collect::, _>>()?, + NonNegativeAmount::from_u64(proto_balance.fee_required) + .map_err(|_| ProposalDecodingError::BalanceInvalid)?, + ) + .map_err(|_| ProposalDecodingError::BalanceInvalid)?; + + let step = Step::from_parts( + &steps, + transaction_request, + payment_pools, + transparent_inputs, + shielded_inputs, + prior_step_inputs, + balance, + step.is_shielding, + ) + .map_err(ProposalDecodingError::ProposalInvalid)?; + + steps.push(step); } - let shielded_inputs = NonEmpty::from_vec(received_notes) - .map(|notes| ShieldedInputs::from_parts(self.anchor_height.into(), notes)); - - let proto_balance = self - .balance - .as_ref() - .ok_or(ProposalDecodingError::BalanceInvalid)?; - let balance = TransactionBalance::new( - proto_balance - .proposed_change - .iter() - .map(|cv| -> Result> { - match cv.pool_type()? { - PoolType::Shielded(ShieldedProtocol::Sapling) => { - Ok(ChangeValue::sapling( - NonNegativeAmount::from_u64(cv.value) - .map_err(|_| ProposalDecodingError::BalanceInvalid)?, - cv.memo - .as_ref() - .map(|bytes| { - MemoBytes::from_bytes(&bytes.value) - .map_err(ProposalDecodingError::MemoInvalid) - }) - .transpose()?, - )) - } - t => Err(ProposalDecodingError::InvalidChangeRecipient(t)), - } - }) - .collect::, _>>()?, - NonNegativeAmount::from_u64(proto_balance.fee_required) - .map_err(|_| ProposalDecodingError::BalanceInvalid)?, - ) - .map_err(|_| ProposalDecodingError::BalanceInvalid)?; - - Proposal::from_parts( - transaction_request, - payment_pools, - transparent_inputs, - shielded_inputs, - balance, + Proposal::multi_step( fee_rule, self.min_target_height.into(), - self.is_shielding, + NonEmpty::from_vec(steps).ok_or(ProposalDecodingError::NoSteps)?, ) .map_err(ProposalDecodingError::ProposalInvalid) } diff --git a/zcash_client_backend/src/proto/proposal.rs b/zcash_client_backend/src/proto/proposal.rs index c8ae14d3b..75bebfd79 100644 --- a/zcash_client_backend/src/proto/proposal.rs +++ b/zcash_client_backend/src/proto/proposal.rs @@ -1,41 +1,50 @@ -/// A data structure that describes the inputs to be consumed and outputs to -/// be produced in a proposed transaction. +/// A data structure that describes the a series of transactions to be created. #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Proposal { + /// The version of this serialization format. #[prost(uint32, tag = "1")] pub proto_version: u32, - /// ZIP 321 serialized transaction request - #[prost(string, tag = "2")] - pub transaction_request: ::prost::alloc::string::String, - /// The vector of selected payment index / output pool mappings. Payment index - /// 0 corresponds to the payment with no explicit index. - #[prost(message, repeated, tag = "3")] - pub payment_output_pools: ::prost::alloc::vec::Vec, - /// The anchor height to be used in creating the transaction, if any. - /// Setting the anchor height to zero will disallow the use of any shielded - /// inputs. - #[prost(uint32, tag = "4")] - pub anchor_height: u32, - /// The inputs to be used in creating the transaction. - #[prost(message, repeated, tag = "5")] - pub inputs: ::prost::alloc::vec::Vec, - /// The total value, fee value, and change outputs of the proposed - /// transaction - #[prost(message, optional, tag = "6")] - pub balance: ::core::option::Option, /// The fee rule used in constructing this proposal - #[prost(enumeration = "FeeRule", tag = "7")] + #[prost(enumeration = "FeeRule", tag = "2")] pub fee_rule: i32, /// The target height for which the proposal was constructed /// /// The chain must contain at least this many blocks in order for the proposal to /// be executed. - #[prost(uint32, tag = "8")] + #[prost(uint32, tag = "3")] pub min_target_height: u32, - /// A flag indicating whether the proposal is for a shielding transaction, + /// The series of transactions to be created. + #[prost(message, repeated, tag = "4")] + pub steps: ::prost::alloc::vec::Vec, +} +/// A data structure that describes the inputs to be consumed and outputs to +/// be produced in a proposed transaction. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ProposalStep { + /// ZIP 321 serialized transaction request + #[prost(string, tag = "1")] + pub transaction_request: ::prost::alloc::string::String, + /// The vector of selected payment index / output pool mappings. Payment index + /// 0 corresponds to the payment with no explicit index. + #[prost(message, repeated, tag = "2")] + pub payment_output_pools: ::prost::alloc::vec::Vec, + /// The anchor height to be used in creating the transaction, if any. + /// Setting the anchor height to zero will disallow the use of any shielded + /// inputs. + #[prost(uint32, tag = "3")] + pub anchor_height: u32, + /// The inputs to be used in creating the transaction. + #[prost(message, repeated, tag = "4")] + pub inputs: ::prost::alloc::vec::Vec, + /// The total value, fee value, and change outputs of the proposed + /// transaction + #[prost(message, optional, tag = "5")] + pub balance: ::core::option::Option, + /// A flag indicating whether the step is for a shielding transaction, /// used for determining which OVK to select for wallet-internal outputs. - #[prost(bool, tag = "9")] + #[prost(bool, tag = "6")] pub is_shielding: bool, } /// A mapping from ZIP 321 payment index to the output pool that has been chosen @@ -49,10 +58,11 @@ pub struct PaymentOutputPool { #[prost(enumeration = "ValuePool", tag = "2")] pub value_pool: i32, } -/// The unique identifier and value for each proposed input. +/// The unique identifier and value for each proposed input that does not +/// require a back-reference to a prior step of the proposal. #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] -pub struct ProposedInput { +pub struct ReceivedOutput { #[prost(bytes = "vec", tag = "1")] pub txid: ::prost::alloc::vec::Vec, #[prost(enumeration = "ValuePool", tag = "2")] @@ -62,12 +72,53 @@ pub struct ProposedInput { #[prost(uint64, tag = "4")] pub value: u64, } +/// A reference a payment in a prior step of the proposal. This payment must +/// belong to the wallet. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PriorStepOutput { + #[prost(uint32, tag = "1")] + pub step_index: u32, + #[prost(uint32, tag = "2")] + pub payment_index: u32, +} +/// A reference a change output from a prior step of the proposal. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PriorStepChange { + #[prost(uint32, tag = "1")] + pub step_index: u32, + #[prost(uint32, tag = "2")] + pub change_index: u32, +} +/// The unique identifier and value for an input to be used in the transaction. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ProposedInput { + #[prost(oneof = "proposed_input::Value", tags = "1, 2, 3")] + pub value: ::core::option::Option, +} +/// Nested message and enum types in `ProposedInput`. +pub mod proposed_input { + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Value { + #[prost(message, tag = "1")] + ReceivedOutput(super::ReceivedOutput), + #[prost(message, tag = "2")] + PriorStepOutput(super::PriorStepOutput), + #[prost(message, tag = "3")] + PriorStepChange(super::PriorStepChange), + } +} /// The proposed change outputs and fee value. #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct TransactionBalance { + /// A list of change output values. #[prost(message, repeated, tag = "1")] pub proposed_change: ::prost::alloc::vec::Vec, + /// The fee to be paid by the proposed transaction, in zatoshis. #[prost(uint64, tag = "2")] pub fee_required: u64, } @@ -76,10 +127,14 @@ pub struct TransactionBalance { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ChangeValue { + /// The value of a change output to be created, in zatoshis. #[prost(uint64, tag = "1")] pub value: u64, + /// The value pool in which the change output should be created. #[prost(enumeration = "ValuePool", tag = "2")] pub value_pool: i32, + /// The optional memo that should be associated with the newly created change output. + /// Memos must not be present for transparent change outputs. #[prost(message, optional, tag = "3")] pub memo: ::core::option::Option, } diff --git a/zcash_client_backend/src/wallet.rs b/zcash_client_backend/src/wallet.rs index 4ee91500b..35b694b61 100644 --- a/zcash_client_backend/src/wallet.rs +++ b/zcash_client_backend/src/wallet.rs @@ -378,6 +378,7 @@ impl orchard_fees::InputView for ReceivedNote TestState { min_confirmations: NonZeroU32, change_memo: Option, ) -> Result< - TxId, + NonEmpty, data_api::error::Error< SqliteClientError, commitment_tree::Error, @@ -477,7 +478,7 @@ impl TestState { ovk_policy: OvkPolicy, min_confirmations: NonZeroU32, ) -> Result< - TxId, + NonEmpty, data_api::error::Error< SqliteClientError, commitment_tree::Error, @@ -608,14 +609,14 @@ impl TestState { ) } - /// Invokes [`create_proposed_transaction`] with the given arguments. - pub(crate) fn create_proposed_transaction( + /// Invokes [`create_proposed_transactions`] with the given arguments. + pub(crate) fn create_proposed_transactions( &mut self, usk: &UnifiedSpendingKey, ovk_policy: OvkPolicy, proposal: &Proposal, ) -> Result< - TxId, + NonEmpty, data_api::error::Error< SqliteClientError, commitment_tree::Error, @@ -628,7 +629,7 @@ impl TestState { { let params = self.network(); let prover = test_prover(); - create_proposed_transaction( + create_proposed_transactions( &mut self.db_data, ¶ms, &prover, @@ -650,7 +651,7 @@ impl TestState { from_addrs: &[TransparentAddress], min_confirmations: u32, ) -> Result< - TxId, + NonEmpty, data_api::error::Error< SqliteClientError, commitment_tree::Error, diff --git a/zcash_client_sqlite/src/wallet.rs b/zcash_client_sqlite/src/wallet.rs index 34b3f8be2..665456473 100644 --- a/zcash_client_sqlite/src/wallet.rs +++ b/zcash_client_sqlite/src/wallet.rs @@ -2316,7 +2316,7 @@ mod tests { ); let txid = st .shield_transparent_funds(&input_selector, value, &usk, &[*taddr], 1) - .unwrap(); + .unwrap()[0]; // The wallet should have zero transparent balance, because the shielding // transaction can be mined. diff --git a/zcash_client_sqlite/src/wallet/sapling.rs b/zcash_client_sqlite/src/wallet/sapling.rs index b8f68ba30..b0a14a122 100644 --- a/zcash_client_sqlite/src/wallet/sapling.rs +++ b/zcash_client_sqlite/src/wallet/sapling.rs @@ -589,10 +589,10 @@ pub(crate) mod tests { .unwrap(); let create_proposed_result = - st.create_proposed_transaction::(&usk, OvkPolicy::Sender, &proposal); - assert_matches!(create_proposed_result, Ok(_)); + st.create_proposed_transactions::(&usk, OvkPolicy::Sender, &proposal); + assert_matches!(&create_proposed_result, Ok(txids) if txids.len() == 1); - let sent_tx_id = create_proposed_result.unwrap(); + let sent_tx_id = create_proposed_result.unwrap()[0]; // Verify that the sent transaction was stored and that we can decrypt the memos let tx = st @@ -864,8 +864,8 @@ pub(crate) mod tests { // Executing the proposal should succeed let txid = st - .create_proposed_transaction::(&usk, OvkPolicy::Sender, &proposal) - .unwrap(); + .create_proposed_transactions::(&usk, OvkPolicy::Sender, &proposal) + .unwrap()[0]; let (h, _) = st.generate_next_block_including(txid); st.scan_cached_blocks(h, 1); @@ -921,8 +921,8 @@ pub(crate) mod tests { // Executing the proposal should succeed assert_matches!( - st.create_proposed_transaction::(&usk, OvkPolicy::Sender, &proposal,), - Ok(_) + st.create_proposed_transactions::(&usk, OvkPolicy::Sender, &proposal,), + Ok(txids) if txids.len() == 1 ); // A second proposal fails because there are no usable notes @@ -1000,8 +1000,8 @@ pub(crate) mod tests { .unwrap(); let txid2 = st - .create_proposed_transaction::(&usk, OvkPolicy::Sender, &proposal) - .unwrap(); + .create_proposed_transactions::(&usk, OvkPolicy::Sender, &proposal) + .unwrap()[0]; let (h, _) = st.generate_next_block_including(txid2); st.scan_cached_blocks(h, 1); @@ -1066,7 +1066,7 @@ pub(crate) mod tests { )?; // Executing the proposal should succeed - let txid = st.create_proposed_transaction(&usk, ovk_policy, &proposal)?; + let txid = st.create_proposed_transactions(&usk, ovk_policy, &proposal)?[0]; // Fetch the transaction from the database let raw_tx: Vec<_> = st @@ -1170,8 +1170,8 @@ pub(crate) mod tests { // Executing the proposal should succeed assert_matches!( - st.create_proposed_transaction::(&usk, OvkPolicy::Sender, &proposal), - Ok(_) + st.create_proposed_transactions::(&usk, OvkPolicy::Sender, &proposal), + Ok(txids) if txids.len() == 1 ); } @@ -1232,8 +1232,8 @@ pub(crate) mod tests { // Executing the proposal should succeed assert_matches!( - st.create_proposed_transaction::(&usk, OvkPolicy::Sender, &proposal), - Ok(_) + st.create_proposed_transactions::(&usk, OvkPolicy::Sender, &proposal), + Ok(txids) if txids.len() == 1 ); } @@ -1310,7 +1310,7 @@ pub(crate) mod tests { OvkPolicy::Sender, NonZeroU32::new(1).unwrap(), ) - .unwrap(); + .unwrap()[0]; let amount_left = (value - (amount_sent + fee_rule.fixed_fee()).unwrap()).unwrap(); let pending_change = (amount_left - amount_legacy_change).unwrap(); @@ -1441,7 +1441,7 @@ pub(crate) mod tests { OvkPolicy::Sender, NonZeroU32::new(1).unwrap(), ) - .unwrap(); + .unwrap()[0]; let (h, _) = st.generate_next_block_including(txid); st.scan_cached_blocks(h, 1);