zcash_client_backend/
proposal.rs

1//! Types related to the construction and evaluation of transaction proposals.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    fmt::{self, Debug, Display},
6};
7
8use nonempty::NonEmpty;
9use zcash_primitives::transaction::TxId;
10use zcash_protocol::{consensus::BlockHeight, value::Zatoshis, PoolType, ShieldedProtocol};
11use zip321::TransactionRequest;
12
13use crate::{
14    fees::TransactionBalance,
15    wallet::{Note, ReceivedNote, WalletTransparentOutput},
16};
17
18/// Errors that can occur in construction of a [`Step`].
19#[derive(Debug, Clone)]
20pub enum ProposalError {
21    /// The total output value of the transaction request is not a valid Zcash amount.
22    RequestTotalInvalid,
23    /// The total of transaction inputs overflows the valid range of Zcash values.
24    Overflow,
25    /// The input total and output total of the payment request are not equal to one another. The
26    /// sum of transaction outputs, change, and fees is required to be exactly equal to the value
27    /// of provided inputs.
28    BalanceError {
29        input_total: Zatoshis,
30        output_total: Zatoshis,
31    },
32    /// The `is_shielding` flag may only be set to `true` under the following conditions:
33    /// * The total of transparent inputs is nonzero
34    /// * There exist no Sapling inputs
35    /// * There provided transaction request is empty; i.e. the only output values specified
36    ///   are change and fee amounts.
37    ShieldingInvalid,
38    /// No anchor information could be obtained for the specified block height.
39    AnchorNotFound(BlockHeight),
40    /// A reference to the output of a prior step is invalid.
41    ReferenceError(StepOutput),
42    /// An attempted double-spend of a prior step output was detected.
43    StepDoubleSpend(StepOutput),
44    /// An attempted double-spend of an output belonging to the wallet was detected.
45    ChainDoubleSpend(PoolType, TxId, u32),
46    /// There was a mismatch between the payments in the proposal's transaction request
47    /// and the payment pool selection values.
48    PaymentPoolsMismatch,
49    /// The proposal tried to spend a change output. Mark the `ChangeValue` as ephemeral if this is intended.
50    SpendsChange(StepOutput),
51    /// A proposal step created an ephemeral output that was not spent in any later step.
52    #[cfg(feature = "transparent-inputs")]
53    EphemeralOutputLeftUnspent(StepOutput),
54    /// The proposal included a payment to a TEX address and a spend from a shielded input in the same step.
55    #[cfg(feature = "transparent-inputs")]
56    PaysTexFromShielded,
57    /// The change strategy provided to input selection failed to correctly generate an ephemeral
58    /// change output when needed for sending to a TEX address.
59    #[cfg(feature = "transparent-inputs")]
60    EphemeralOutputsInvalid,
61}
62
63impl Display for ProposalError {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            ProposalError::RequestTotalInvalid => write!(
67                f,
68                "The total requested output value is not a valid Zcash amount."
69            ),
70            ProposalError::Overflow => write!(
71                f,
72                "The total of transaction inputs overflows the valid range of Zcash values."
73            ),
74            ProposalError::BalanceError {
75                input_total,
76                output_total,
77            } => write!(
78                f,
79                "Balance error: the output total {} was not equal to the input total {}",
80                u64::from(*output_total),
81                u64::from(*input_total)
82            ),
83            ProposalError::ShieldingInvalid => write!(
84                f,
85                "The proposal violates the rules for a shielding transaction."
86            ),
87            ProposalError::AnchorNotFound(h) => {
88                write!(f, "Unable to compute anchor for block height {:?}", h)
89            }
90            ProposalError::ReferenceError(r) => {
91                write!(f, "No prior step output found for reference {:?}", r)
92            }
93            ProposalError::StepDoubleSpend(r) => write!(
94                f,
95                "The proposal uses the output of step {:?} in more than one place.",
96                r
97            ),
98            ProposalError::ChainDoubleSpend(pool, txid, index) => write!(
99                f,
100                "The proposal attempts to spend the same output twice: {}, {}, {}",
101                pool, txid, index
102            ),
103            ProposalError::PaymentPoolsMismatch => write!(
104                f,
105                "The chosen payment pools did not match the payments of the transaction request."
106            ),
107            ProposalError::SpendsChange(r) => write!(
108                f,
109                "The proposal attempts to spends the change output created at step {:?}.",
110                r,
111            ),
112            #[cfg(feature = "transparent-inputs")]
113            ProposalError::EphemeralOutputLeftUnspent(r) => write!(
114                f,
115                "The proposal created an ephemeral output at step {:?} that was not spent in any later step.",
116                r,
117            ),
118            #[cfg(feature = "transparent-inputs")]
119            ProposalError::PaysTexFromShielded => write!(
120                f,
121                "The proposal included a payment to a TEX address and a spend from a shielded input in the same step.",
122            ),
123            #[cfg(feature = "transparent-inputs")]
124            ProposalError::EphemeralOutputsInvalid => write!(
125                f,
126                "The proposal generator failed to correctly generate an ephemeral change output when needed for sending to a TEX address."
127            ),
128        }
129    }
130}
131
132impl std::error::Error for ProposalError {}
133
134/// The Sapling inputs to a proposed transaction.
135#[derive(Clone, PartialEq, Eq)]
136pub struct ShieldedInputs<NoteRef> {
137    anchor_height: BlockHeight,
138    notes: NonEmpty<ReceivedNote<NoteRef, Note>>,
139}
140
141impl<NoteRef> ShieldedInputs<NoteRef> {
142    /// Constructs a [`ShieldedInputs`] from its constituent parts.
143    pub fn from_parts(
144        anchor_height: BlockHeight,
145        notes: NonEmpty<ReceivedNote<NoteRef, Note>>,
146    ) -> Self {
147        Self {
148            anchor_height,
149            notes,
150        }
151    }
152
153    /// Returns the anchor height for Sapling inputs that should be used when constructing the
154    /// proposed transaction.
155    pub fn anchor_height(&self) -> BlockHeight {
156        self.anchor_height
157    }
158
159    /// Returns the list of Sapling notes to be used as inputs to the proposed transaction.
160    pub fn notes(&self) -> &NonEmpty<ReceivedNote<NoteRef, Note>> {
161        &self.notes
162    }
163}
164
165/// A proposal for a series of transactions to be created.
166///
167/// Each step of the proposal represents a separate transaction to be created. At present, only
168/// transparent outputs of earlier steps may be spent in later steps; the ability to chain shielded
169/// transaction steps may be added in a future update.
170#[derive(Clone, PartialEq, Eq)]
171pub struct Proposal<FeeRuleT, NoteRef> {
172    fee_rule: FeeRuleT,
173    min_target_height: BlockHeight,
174    steps: NonEmpty<Step<NoteRef>>,
175}
176
177impl<FeeRuleT, NoteRef> Proposal<FeeRuleT, NoteRef> {
178    /// Constructs a validated multi-step [`Proposal`].
179    ///
180    /// This operation validates the proposal for agreement between outputs and inputs
181    /// in the case of multi-step proposals, and ensures that no double-spends are being
182    /// proposed.
183    ///
184    /// Parameters:
185    /// * `fee_rule`: The fee rule observed by the proposed transaction.
186    /// * `min_target_height`: The minimum block height at which the transaction may be created.
187    /// * `steps`: A vector of steps that make up the proposal.
188    pub fn multi_step(
189        fee_rule: FeeRuleT,
190        min_target_height: BlockHeight,
191        steps: NonEmpty<Step<NoteRef>>,
192    ) -> Result<Self, ProposalError> {
193        let mut consumed_chain_inputs: BTreeSet<(PoolType, TxId, u32)> = BTreeSet::new();
194        let mut consumed_prior_inputs: BTreeSet<StepOutput> = BTreeSet::new();
195
196        for (i, step) in steps.iter().enumerate() {
197            for prior_ref in step.prior_step_inputs() {
198                // check that there are no forward references
199                if prior_ref.step_index() >= i {
200                    return Err(ProposalError::ReferenceError(*prior_ref));
201                }
202                // check that the reference is valid
203                let prior_step = &steps[prior_ref.step_index()];
204                match prior_ref.output_index() {
205                    StepOutputIndex::Payment(idx) => {
206                        if prior_step.transaction_request().payments().len() <= idx {
207                            return Err(ProposalError::ReferenceError(*prior_ref));
208                        }
209                    }
210                    StepOutputIndex::Change(idx) => {
211                        if prior_step.balance().proposed_change().len() <= idx {
212                            return Err(ProposalError::ReferenceError(*prior_ref));
213                        }
214                    }
215                }
216                // check that there are no double-spends
217                if !consumed_prior_inputs.insert(*prior_ref) {
218                    return Err(ProposalError::StepDoubleSpend(*prior_ref));
219                }
220            }
221
222            for t_out in step.transparent_inputs() {
223                let key = (
224                    PoolType::TRANSPARENT,
225                    TxId::from_bytes(*t_out.outpoint().hash()),
226                    t_out.outpoint().n(),
227                );
228                if !consumed_chain_inputs.insert(key) {
229                    return Err(ProposalError::ChainDoubleSpend(key.0, key.1, key.2));
230                }
231            }
232
233            for s_out in step.shielded_inputs().iter().flat_map(|i| i.notes().iter()) {
234                let key = (
235                    match &s_out.note() {
236                        Note::Sapling(_) => PoolType::SAPLING,
237                        #[cfg(feature = "orchard")]
238                        Note::Orchard(_) => PoolType::ORCHARD,
239                    },
240                    *s_out.txid(),
241                    s_out.output_index().into(),
242                );
243                if !consumed_chain_inputs.insert(key) {
244                    return Err(ProposalError::ChainDoubleSpend(key.0, key.1, key.2));
245                }
246            }
247        }
248
249        Ok(Self {
250            fee_rule,
251            min_target_height,
252            steps,
253        })
254    }
255
256    /// Constructs a validated [`Proposal`] having only a single step from its constituent parts.
257    ///
258    /// This operation validates the proposal for balance consistency and agreement between
259    /// the `is_shielding` flag and the structure of the proposal.
260    ///
261    /// Parameters:
262    /// * `transaction_request`: The ZIP 321 transaction request describing the payments to be
263    ///   made.
264    /// * `payment_pools`: A map from payment index to pool type.
265    /// * `transparent_inputs`: The set of previous transparent outputs to be spent.
266    /// * `shielded_inputs`: The sets of previous shielded outputs to be spent.
267    /// * `balance`: The change outputs to be added the transaction and the fee to be paid.
268    /// * `fee_rule`: The fee rule observed by the proposed transaction.
269    /// * `min_target_height`: The minimum block height at which the transaction may be created.
270    /// * `is_shielding`: A flag that identifies whether this is a wallet-internal shielding
271    ///   transaction.
272    #[allow(clippy::too_many_arguments)]
273    pub fn single_step(
274        transaction_request: TransactionRequest,
275        payment_pools: BTreeMap<usize, PoolType>,
276        transparent_inputs: Vec<WalletTransparentOutput>,
277        shielded_inputs: Option<ShieldedInputs<NoteRef>>,
278        balance: TransactionBalance,
279        fee_rule: FeeRuleT,
280        min_target_height: BlockHeight,
281        is_shielding: bool,
282    ) -> Result<Self, ProposalError> {
283        Ok(Self {
284            fee_rule,
285            min_target_height,
286            steps: NonEmpty::singleton(Step::from_parts(
287                &[],
288                transaction_request,
289                payment_pools,
290                transparent_inputs,
291                shielded_inputs,
292                vec![],
293                balance,
294                is_shielding,
295            )?),
296        })
297    }
298
299    /// Returns the fee rule to be used by the transaction builder.
300    pub fn fee_rule(&self) -> &FeeRuleT {
301        &self.fee_rule
302    }
303
304    /// Returns the target height for which the proposal was prepared.
305    ///
306    /// The chain must contain at least this many blocks in order for the proposal to
307    /// be executed.
308    pub fn min_target_height(&self) -> BlockHeight {
309        self.min_target_height
310    }
311
312    /// Returns the steps of the proposal. Each step corresponds to an independent transaction to
313    /// be generated as a result of this proposal.
314    pub fn steps(&self) -> &NonEmpty<Step<NoteRef>> {
315        &self.steps
316    }
317}
318
319impl<FeeRuleT: Debug, NoteRef> Debug for Proposal<FeeRuleT, NoteRef> {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        f.debug_struct("Proposal")
322            .field("fee_rule", &self.fee_rule)
323            .field("min_target_height", &self.min_target_height)
324            .field("steps", &self.steps)
325            .finish()
326    }
327}
328
329/// A reference to either a payment or change output within a step.
330#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
331pub enum StepOutputIndex {
332    Payment(usize),
333    Change(usize),
334}
335
336/// A reference to the output of a step in a proposal.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
338pub struct StepOutput {
339    step_index: usize,
340    output_index: StepOutputIndex,
341}
342
343impl StepOutput {
344    /// Constructs a new [`StepOutput`] from its constituent parts.
345    pub fn new(step_index: usize, output_index: StepOutputIndex) -> Self {
346        Self {
347            step_index,
348            output_index,
349        }
350    }
351
352    /// Returns the step index to which this reference refers.
353    pub fn step_index(&self) -> usize {
354        self.step_index
355    }
356
357    /// Returns the identifier for the payment or change output within
358    /// the referenced step.
359    pub fn output_index(&self) -> StepOutputIndex {
360        self.output_index
361    }
362}
363
364/// The inputs to be consumed and outputs to be produced in a proposed transaction.
365#[derive(Clone, PartialEq, Eq)]
366pub struct Step<NoteRef> {
367    transaction_request: TransactionRequest,
368    payment_pools: BTreeMap<usize, PoolType>,
369    transparent_inputs: Vec<WalletTransparentOutput>,
370    shielded_inputs: Option<ShieldedInputs<NoteRef>>,
371    prior_step_inputs: Vec<StepOutput>,
372    balance: TransactionBalance,
373    is_shielding: bool,
374}
375
376impl<NoteRef> Step<NoteRef> {
377    /// Constructs a validated [`Step`] from its constituent parts.
378    ///
379    /// This operation validates the proposal for balance consistency and agreement between
380    /// the `is_shielding` flag and the structure of the proposal.
381    ///
382    /// Parameters:
383    /// * `transaction_request`: The ZIP 321 transaction request describing the payments
384    ///   to be made.
385    /// * `payment_pools`: A map from payment index to pool type. The set of payment indices
386    ///   provided here must exactly match the set of payment indices in the [`TransactionRequest`],
387    ///   and the selected pool for an index must correspond to a valid receiver of the
388    ///   address at that index (or the address itself in the case of bare transparent or Sapling
389    ///   addresses).
390    /// * `transparent_inputs`: The set of previous transparent outputs to be spent.
391    /// * `shielded_inputs`: The sets of previous shielded outputs to be spent.
392    /// * `balance`: The change outputs to be added the transaction and the fee to be paid.
393    /// * `is_shielding`: A flag that identifies whether this is a wallet-internal shielding
394    ///   transaction.
395    #[allow(clippy::too_many_arguments)]
396    pub fn from_parts(
397        prior_steps: &[Step<NoteRef>],
398        transaction_request: TransactionRequest,
399        payment_pools: BTreeMap<usize, PoolType>,
400        transparent_inputs: Vec<WalletTransparentOutput>,
401        shielded_inputs: Option<ShieldedInputs<NoteRef>>,
402        prior_step_inputs: Vec<StepOutput>,
403        balance: TransactionBalance,
404        is_shielding: bool,
405    ) -> Result<Self, ProposalError> {
406        // Verify that the set of payment pools matches exactly a set of valid payment recipients
407        if transaction_request.payments().len() != payment_pools.len() {
408            return Err(ProposalError::PaymentPoolsMismatch);
409        }
410        for (idx, pool) in &payment_pools {
411            if !transaction_request
412                .payments()
413                .get(idx)
414                .iter()
415                .any(|payment| payment.recipient_address().can_receive_as(*pool))
416            {
417                return Err(ProposalError::PaymentPoolsMismatch);
418            }
419        }
420
421        let transparent_input_total = transparent_inputs
422            .iter()
423            .map(|out| out.txout().value)
424            .try_fold(Zatoshis::ZERO, |acc, a| {
425                (acc + a).ok_or(ProposalError::Overflow)
426            })?;
427
428        let shielded_input_total = shielded_inputs
429            .iter()
430            .flat_map(|s_in| s_in.notes().iter())
431            .map(|out| out.note().value())
432            .try_fold(Zatoshis::ZERO, |acc, a| (acc + a))
433            .ok_or(ProposalError::Overflow)?;
434
435        let prior_step_input_total = prior_step_inputs
436            .iter()
437            .map(|s_ref| {
438                let step = prior_steps
439                    .get(s_ref.step_index)
440                    .ok_or(ProposalError::ReferenceError(*s_ref))?;
441                Ok(match s_ref.output_index {
442                    StepOutputIndex::Payment(i) => step
443                        .transaction_request
444                        .payments()
445                        .get(&i)
446                        .ok_or(ProposalError::ReferenceError(*s_ref))?
447                        .amount(),
448                    StepOutputIndex::Change(i) => step
449                        .balance
450                        .proposed_change()
451                        .get(i)
452                        .ok_or(ProposalError::ReferenceError(*s_ref))?
453                        .value(),
454                })
455            })
456            .collect::<Result<Vec<_>, _>>()?
457            .into_iter()
458            .try_fold(Zatoshis::ZERO, |acc, a| (acc + a))
459            .ok_or(ProposalError::Overflow)?;
460
461        let input_total = (transparent_input_total + shielded_input_total + prior_step_input_total)
462            .ok_or(ProposalError::Overflow)?;
463
464        let request_total = transaction_request
465            .total()
466            .map_err(|_| ProposalError::RequestTotalInvalid)?;
467        let output_total = (request_total + balance.total()).ok_or(ProposalError::Overflow)?;
468
469        if is_shielding
470            && (transparent_input_total == Zatoshis::ZERO
471                || shielded_input_total > Zatoshis::ZERO
472                || request_total > Zatoshis::ZERO)
473        {
474            return Err(ProposalError::ShieldingInvalid);
475        }
476
477        if input_total == output_total {
478            Ok(Self {
479                transaction_request,
480                payment_pools,
481                transparent_inputs,
482                shielded_inputs,
483                prior_step_inputs,
484                balance,
485                is_shielding,
486            })
487        } else {
488            Err(ProposalError::BalanceError {
489                input_total,
490                output_total,
491            })
492        }
493    }
494
495    /// Returns the transaction request that describes the payments to be made.
496    pub fn transaction_request(&self) -> &TransactionRequest {
497        &self.transaction_request
498    }
499    /// Returns the map from payment index to the pool that has been selected
500    /// for the output that will fulfill that payment.
501    pub fn payment_pools(&self) -> &BTreeMap<usize, PoolType> {
502        &self.payment_pools
503    }
504    /// Returns the transparent inputs that have been selected to fund the transaction.
505    pub fn transparent_inputs(&self) -> &[WalletTransparentOutput] {
506        &self.transparent_inputs
507    }
508    /// Returns the shielded inputs that have been selected to fund the transaction.
509    pub fn shielded_inputs(&self) -> Option<&ShieldedInputs<NoteRef>> {
510        self.shielded_inputs.as_ref()
511    }
512    /// Returns the inputs that should be obtained from the outputs of the transaction
513    /// created to satisfy a previous step of the proposal.
514    pub fn prior_step_inputs(&self) -> &[StepOutput] {
515        self.prior_step_inputs.as_ref()
516    }
517    /// Returns the change outputs to be added to the transaction and the fee to be paid.
518    pub fn balance(&self) -> &TransactionBalance {
519        &self.balance
520    }
521    /// Returns a flag indicating whether or not the proposed transaction
522    /// is exclusively wallet-internal (if it does not involve any external
523    /// recipients).
524    pub fn is_shielding(&self) -> bool {
525        self.is_shielding
526    }
527
528    /// Returns whether or not this proposal requires interaction with the specified pool.
529    pub fn involves(&self, pool_type: PoolType) -> bool {
530        let input_in_this_pool = || match pool_type {
531            PoolType::Transparent => self.is_shielding || !self.transparent_inputs.is_empty(),
532            PoolType::Shielded(ShieldedProtocol::Sapling) => {
533                self.shielded_inputs.iter().any(|s_in| {
534                    s_in.notes()
535                        .iter()
536                        .any(|note| matches!(note.note(), Note::Sapling(_)))
537                })
538            }
539            #[cfg(feature = "orchard")]
540            PoolType::Shielded(ShieldedProtocol::Orchard) => {
541                self.shielded_inputs.iter().any(|s_in| {
542                    s_in.notes()
543                        .iter()
544                        .any(|note| matches!(note.note(), Note::Orchard(_)))
545                })
546            }
547            #[cfg(not(feature = "orchard"))]
548            PoolType::Shielded(ShieldedProtocol::Orchard) => false,
549        };
550        let output_in_this_pool = || self.payment_pools().values().any(|pool| *pool == pool_type);
551        let change_in_this_pool = || {
552            self.balance
553                .proposed_change()
554                .iter()
555                .any(|c| c.output_pool() == pool_type)
556        };
557
558        input_in_this_pool() || output_in_this_pool() || change_in_this_pool()
559    }
560}
561
562impl<NoteRef> Debug for Step<NoteRef> {
563    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
564        f.debug_struct("Step")
565            .field("transaction_request", &self.transaction_request)
566            .field("transparent_inputs", &self.transparent_inputs)
567            .field(
568                "shielded_inputs",
569                &self.shielded_inputs().map(|i| i.notes.len()),
570            )
571            .field("prior_step_inputs", &self.prior_step_inputs)
572            .field(
573                "anchor_height",
574                &self.shielded_inputs().map(|i| i.anchor_height),
575            )
576            .field("balance", &self.balance)
577            .field("is_shielding", &self.is_shielding)
578            .finish_non_exhaustive()
579    }
580}