Skip to main content

zcash_client_backend/data_api/
wallet.rs

1//! # Functions for creating Zcash transactions that spend funds belonging to the wallet
2//!
3//!
4//! This module contains several different ways of creating Zcash transactions. This module is
5//! designed around the idea that a Zcash wallet holds its funds in notes in either the Orchard
6//! or Sapling shielded pool. In order to better preserve users' privacy, it does not provide any
7//! functionality that allows users to directly spend transparent funds except by sending them to a
8//! shielded internal address belonging to their wallet.
9//!
10//! The important high-level operations provided by this module are [`propose_transfer`],
11//! and [`create_proposed_transactions`].
12//!
13//! [`propose_transfer`] takes a [`TransactionRequest`] object, selects inputs notes and
14//! computes the fees required to satisfy that request, and returns a [`Proposal`] object that
15//! describes the transaction to be made.
16//!
17//! [`create_proposed_transactions`] constructs one or more Zcash [`Transaction`]s based upon a
18//! provided [`Proposal`], stores them to the wallet database, and returns the [`TxId`] for each
19//! constructed transaction to the caller. The caller can then use the
20//! [`WalletRead::get_transaction`] method to retrieve the newly constructed transactions. It is
21//! the responsibility of the caller to retrieve and serialize the transactions and submit them for
22//! inclusion into the Zcash blockchain.
23//!
24#![cfg_attr(
25    feature = "transparent-inputs",
26    doc = "
27Another important high-level operation provided by this module is [`propose_shielding`], which
28takes a set of transparent source addresses, and constructs a [`Proposal`] to send those funds
29to a wallet-internal shielded address, as described in [ZIP 316](https://zips.z.cash/zip-0316).
30
31[`propose_shielding`]: crate::data_api::wallet::propose_shielding
32"
33)]
34//! [`TransactionRequest`]: crate::zip321::TransactionRequest
35//! [`propose_transfer`]: crate::data_api::wallet::propose_transfer
36
37use nonempty::NonEmpty;
38use rand_core::CryptoRng;
39use std::{
40    num::NonZeroU32,
41    ops::{Add, Sub},
42    time::SystemTime,
43};
44
45use shardtree::error::{QueryError, ShardTreeError};
46
47pub use super::locking::{LockRequest, unlock_proposal_inputs};
48use super::{InputSource, locking::lock_proposal_inputs};
49use crate::{
50    data_api::{
51        Account, MaxSpendMode, NoteCommitmentTree, SentTransaction, SentTransactionOutput,
52        WalletCommitmentTrees, WalletRead, WalletWrite,
53        error::{AddressExpiryError, Error},
54        wallet::input_selection::propose_send_max,
55    },
56    decrypt_transaction,
57    fees::{
58        ChangeStrategy, DustOutputPolicy, StandardFeeRule, standard::SingleOutputChangeStrategy,
59    },
60    proposal::{Proposal, ProposalError, Step, StepOutputIndex},
61    util::Clock,
62    wallet::{Note, OvkPolicy, Recipient},
63};
64use sapling::{
65    note_encryption::{PreparedIncomingViewingKey, try_sapling_note_decryption},
66    prover::{OutputProver, SpendProver},
67};
68use transparent::{address::TransparentAddress, builder::TransparentSigningSet, bundle::OutPoint};
69use zcash_address::ZcashAddress;
70use zcash_keys::{
71    address::{Address, UnifiedAddress},
72    keys::{UnifiedFullViewingKey, UnifiedSpendingKey},
73};
74use zcash_primitives::transaction::{
75    Transaction, TxId, TxVersion,
76    builder::{BuildConfig, BuildResult, Builder, BundlePadding},
77    components::sapling::zip212_enforcement,
78    fees::FeeRule,
79};
80use zcash_protocol::{
81    PoolType, ShieldedPool,
82    consensus::{self, BlockHeight},
83    memo::MemoBytes,
84    value::Zatoshis,
85    zip318::AnchorGrid,
86};
87use zip32::Scope;
88use zip321::Payment;
89#[cfg(feature = "orchard")]
90use {
91    crate::data_api::anchor_retention::PoolMigrationParams,
92    zcash_protocol::{consensus::NetworkUpgrade, zip318::PoolMigrationConstants},
93};
94
95#[cfg(feature = "transparent-inputs")]
96use {
97    super::CoinbaseFilter,
98    crate::{
99        fees::ChangeValue,
100        proposal::StepOutput,
101        wallet::{TransparentAddressMetadata, TransparentAddressSource},
102    },
103    core::convert::Infallible,
104    input_selection::ShieldingSelector,
105    std::collections::HashMap,
106    transparent::bundle::TxOut,
107};
108
109#[cfg(feature = "transparent-key-import")]
110use zcash_script::script::{self as zs_script, Evaluable};
111
112#[cfg(feature = "pczt")]
113use {
114    crate::data_api::error::PcztError,
115    bip32::ChildNumber,
116    orchard::note_encryption::{IronwoodDomain, OrchardDomain},
117    pczt::roles::{
118        creator::Creator, io_finalizer::IoFinalizer, redactor::Redactor,
119        spend_finalizer::SpendFinalizer, tx_extractor::TransactionExtractor, updater::Updater,
120    },
121    sapling::note_encryption::SaplingDomain,
122    serde::{Deserialize, Serialize},
123    std::collections::BTreeMap,
124    transparent::pczt::Bip32Derivation,
125    zcash_note_encryption::{Domain, ShieldedOutput, try_output_recovery_with_pkd_esk},
126    zcash_protocol::{consensus::NetworkConstants, value::BalanceError},
127};
128
129pub mod input_selection;
130use input_selection::{
131    GreedyInputSelector, GreedyInputSelectorError, InputSelector, InputSelectorError,
132};
133
134#[cfg(feature = "pczt")]
135const PROPRIETARY_PROPOSAL_INFO: &str = "zcash_client_backend:proposal_info";
136#[cfg(feature = "pczt")]
137const PROPRIETARY_OUTPUT_INFO: &str = "zcash_client_backend:output_info";
138
139#[cfg(feature = "orchard")]
140fn ironwood_active_at<ParamsT: consensus::Parameters, H: Into<BlockHeight>>(
141    params: &ParamsT,
142    target_height: H,
143) -> bool {
144    params.is_nu_active(NetworkUpgrade::Nu6_3, target_height.into())
145}
146
147#[cfg(feature = "pczt")]
148fn serialize_target_height<S>(
149    target_height: &TargetHeight,
150    serializer: S,
151) -> Result<S::Ok, S::Error>
152where
153    S: serde::Serializer,
154{
155    let u: u32 = BlockHeight::from(*target_height).into();
156    u.serialize(serializer)
157}
158
159#[cfg(feature = "pczt")]
160fn deserialize_target_height<'de, D>(deserializer: D) -> Result<TargetHeight, D::Error>
161where
162    D: serde::Deserializer<'de>,
163{
164    let u = u32::deserialize(deserializer)?;
165    Ok(BlockHeight::from_u32(u).into())
166}
167
168/// Information about the proposal from which a PCZT was created.
169///
170/// Stored under the proprietary field `PROPRIETARY_PROPOSAL_INFO`.
171#[cfg(feature = "pczt")]
172#[derive(Serialize, Deserialize)]
173struct ProposalInfo<AccountId> {
174    from_account: AccountId,
175    #[serde(
176        serialize_with = "serialize_target_height",
177        deserialize_with = "deserialize_target_height"
178    )]
179    target_height: TargetHeight,
180}
181
182/// Reduced version of [`Recipient`] stored inside a PCZT.
183///
184/// Stored under the proprietary field `PROPRIETARY_OUTPUT_INFO`.
185#[cfg(feature = "pczt")]
186#[derive(Serialize, Deserialize)]
187enum PcztRecipient<AccountId> {
188    External,
189    #[cfg(feature = "transparent-inputs")]
190    EphemeralTransparent {
191        receiving_account: AccountId,
192    },
193    InternalShielded {
194        receiving_account: AccountId,
195    },
196    // This variant is placed at the end of the enum in order to preserve the encoded
197    // representation of the prior variants.
198    #[cfg(feature = "transparent-inputs")]
199    InternalTransparent {
200        receiving_account: AccountId,
201    },
202}
203
204#[cfg(feature = "pczt")]
205impl<AccountId: Copy> PcztRecipient<AccountId> {
206    fn from_shielded_recipient(
207        recipient: ShieldedBuildRecipient<AccountId>,
208    ) -> (Self, Option<ZcashAddress>) {
209        match recipient {
210            ShieldedBuildRecipient::External {
211                recipient_address, ..
212            } => (PcztRecipient::External, Some(recipient_address)),
213            ShieldedBuildRecipient::InternalShielded {
214                receiving_account,
215                external_address,
216            } => (
217                PcztRecipient::InternalShielded { receiving_account },
218                external_address,
219            ),
220        }
221    }
222
223    fn from_transparent_recipient(
224        recipient: TransparentBuildRecipient<AccountId>,
225    ) -> (Self, Option<ZcashAddress>) {
226        match recipient {
227            TransparentBuildRecipient::External {
228                recipient_address, ..
229            } => (PcztRecipient::External, Some(recipient_address)),
230            #[cfg(feature = "transparent-inputs")]
231            TransparentBuildRecipient::EphemeralTransparent {
232                receiving_account, ..
233            } => (
234                PcztRecipient::EphemeralTransparent { receiving_account },
235                None,
236            ),
237            #[cfg(feature = "transparent-inputs")]
238            TransparentBuildRecipient::InternalTransparent {
239                receiving_account, ..
240            } => (
241                PcztRecipient::InternalTransparent { receiving_account },
242                None,
243            ),
244        }
245    }
246}
247
248/// Scans a [`Transaction`] for any information that can be decrypted by the accounts in
249/// the wallet, and saves it to the wallet.
250pub fn decrypt_and_store_transaction<ParamsT, DbT>(
251    params: &ParamsT,
252    data: &mut DbT,
253    tx: &Transaction,
254    mined_height: Option<BlockHeight>,
255) -> Result<(), <DbT as WalletRead>::Error>
256where
257    ParamsT: consensus::Parameters,
258    DbT: WalletWrite,
259{
260    // Fetch the UnifiedFullViewingKeys we are tracking
261    let ufvks = data.get_unified_full_viewing_keys()?;
262
263    data.store_decrypted_tx(decrypt_transaction(
264        params,
265        mined_height.map_or_else(|| data.get_tx_height(tx.txid()), |h| Ok(Some(h)))?,
266        data.chain_height()?,
267        tx,
268        &ufvks,
269    ))?;
270
271    Ok(())
272}
273
274/// Errors that may be generated in construction of proposals for shielded->shielded or
275/// shielded->transparent transfers.
276pub type ProposeTransferErrT<DbT, CommitmentTreeErrT, InputsT, ChangeT> = Error<
277    <DbT as WalletRead>::Error,
278    CommitmentTreeErrT,
279    <InputsT as InputSelector>::Error,
280    <<ChangeT as ChangeStrategy>::FeeRule as FeeRule>::Error,
281    <ChangeT as ChangeStrategy>::Error,
282    <<InputsT as InputSelector>::InputSource as InputSource>::NoteRef,
283>;
284
285/// Errors that may be generated in construction of proposals for shielded->shielded or
286/// shielded->transparent transactions that transfer the maximum value available within an account
287/// and do not produce change outputs.
288pub type ProposeSendMaxErrT<DbT, CommitmentTreeErrT, FeeRuleT> = Error<
289    <DbT as WalletRead>::Error,
290    CommitmentTreeErrT,
291    GreedyInputSelectorError,
292    <FeeRuleT as FeeRule>::Error,
293    <FeeRuleT as FeeRule>::Error,
294    <DbT as InputSource>::NoteRef,
295>;
296
297/// Errors that may be generated in construction of proposals for transparent->shielded
298/// wallet-internal transfers.
299#[cfg(feature = "transparent-inputs")]
300pub type ProposeShieldingErrT<DbT, CommitmentTreeErrT, InputsT, ChangeT> = Error<
301    <DbT as WalletRead>::Error,
302    CommitmentTreeErrT,
303    <InputsT as ShieldingSelector>::Error,
304    <<ChangeT as ChangeStrategy>::FeeRule as FeeRule>::Error,
305    <ChangeT as ChangeStrategy>::Error,
306    Infallible,
307>;
308
309/// Errors that may be generated in combined creation and execution of transaction proposals.
310pub type CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N> = Error<
311    <DbT as WalletRead>::Error,
312    <DbT as WalletCommitmentTrees>::Error,
313    InputsErrT,
314    <FeeRuleT as FeeRule>::Error,
315    ChangeErrT,
316    N,
317>;
318
319/// Errors that may be generated in the execution of proposals that may send shielded inputs.
320pub type TransferErrT<DbT, InputsT, ChangeT> = Error<
321    <DbT as WalletRead>::Error,
322    <DbT as WalletCommitmentTrees>::Error,
323    <InputsT as InputSelector>::Error,
324    <<ChangeT as ChangeStrategy>::FeeRule as FeeRule>::Error,
325    <ChangeT as ChangeStrategy>::Error,
326    <<InputsT as InputSelector>::InputSource as InputSource>::NoteRef,
327>;
328
329/// Errors that may be generated in the execution of shielding proposals.
330#[cfg(feature = "transparent-inputs")]
331pub type ShieldErrT<DbT, InputsT, ChangeT> = Error<
332    <DbT as WalletRead>::Error,
333    <DbT as WalletCommitmentTrees>::Error,
334    <InputsT as ShieldingSelector>::Error,
335    <<ChangeT as ChangeStrategy>::FeeRule as FeeRule>::Error,
336    <ChangeT as ChangeStrategy>::Error,
337    Infallible,
338>;
339
340/// Errors that may be generated when extracting a transaction from a PCZT.
341#[cfg(feature = "pczt")]
342pub type ExtractErrT<DbT, N> = Error<
343    <DbT as WalletRead>::Error,
344    <DbT as WalletCommitmentTrees>::Error,
345    Infallible,
346    Infallible,
347    Infallible,
348    N,
349>;
350
351/// A wrapper type around [`BlockHeight`] that represents the _next_ chain tip.
352///
353/// Addition and subtraction are provided by proxying to [`BlockHeight`].
354#[repr(transparent)]
355#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
356pub struct TargetHeight(BlockHeight);
357
358impl TargetHeight {
359    /// Subtracts the provided value from this height, returning [`zcash_protocol::consensus::H0`]
360    /// if this would result in underflow of the wrapped `u32`.
361    pub fn saturating_sub(self, value: u32) -> BlockHeight {
362        self.0.saturating_sub(value)
363    }
364}
365
366impl From<BlockHeight> for TargetHeight {
367    fn from(value: BlockHeight) -> Self {
368        TargetHeight(value)
369    }
370}
371
372impl From<TargetHeight> for BlockHeight {
373    fn from(value: TargetHeight) -> Self {
374        value.0
375    }
376}
377
378impl From<TargetHeight> for u32 {
379    fn from(value: TargetHeight) -> Self {
380        u32::from(value.0)
381    }
382}
383
384impl From<u32> for TargetHeight {
385    fn from(value: u32) -> Self {
386        TargetHeight(BlockHeight::from_u32(value))
387    }
388}
389
390impl<I> Add<I> for TargetHeight
391where
392    BlockHeight: Add<I>,
393{
394    type Output = <BlockHeight as Add<I>>::Output;
395
396    fn add(self, rhs: I) -> Self::Output {
397        self.0 + rhs
398    }
399}
400
401impl<I> Sub<I> for TargetHeight
402where
403    BlockHeight: Sub<I>,
404{
405    type Output = <BlockHeight as Sub<I>>::Output;
406
407    fn sub(self, rhs: I) -> Self::Output {
408        self.0 - rhs
409    }
410}
411
412/// A description of the policy that is used to determine what notes are available for spending,
413/// based upon the number of confirmations (the number of blocks in the chain since and including
414/// the block in which a note was produced.)
415///
416/// See [`ZIP 315`] for details including the definitions of "trusted" and "untrusted" notes.
417///
418/// An error indicating that a [`ConfirmationsPolicy`] could not be constructed because the
419/// trusted confirmation count exceeds the untrusted confirmation count.
420#[derive(Clone, Copy, Debug, PartialEq, Eq)]
421pub struct ConfirmationsPolicyError;
422
423impl core::fmt::Display for ConfirmationsPolicyError {
424    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
425        write!(
426            f,
427            "Trusted confirmations must not exceed untrusted confirmations"
428        )
429    }
430}
431
432/// [`ZIP 315`]: https://zips.z.cash/zip-0315
433#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434pub struct ConfirmationsPolicy {
435    trusted: NonZeroU32,
436    untrusted: NonZeroU32,
437    #[cfg(feature = "transparent-inputs")]
438    allow_zero_conf_shielding: bool,
439}
440
441/// The default confirmations policy according to [`ZIP 315`].
442///
443/// * Require 3 confirmations for "trusted" transaction outputs (outputs produced by the wallet)
444/// * Require 10 confirmations for "untrusted" outputs (those sent to the wallet by external/third
445///   parties)
446/// * Allow zero-conf shielding of transparent UTXOs irrespective of their origin, but treat the
447///   resulting shielding transaction's outputs as though the original transparent UTXOs had
448///   instead been received as untrusted shielded outputs.
449///
450/// [`ZIP 315`]: https://zips.z.cash/zip-0315
451impl Default for ConfirmationsPolicy {
452    fn default() -> Self {
453        ConfirmationsPolicy {
454            // 3
455            trusted: NonZeroU32::MIN.saturating_add(2),
456            // 10
457            untrusted: NonZeroU32::MIN.saturating_add(9),
458            #[cfg(feature = "transparent-inputs")]
459            allow_zero_conf_shielding: true,
460        }
461    }
462}
463
464impl ConfirmationsPolicy {
465    /// A policy to use the minimum number of confirmations possible: 1 confirmation for shielded
466    /// notes irrespective of origin, and 0 confirmations for spends of transparent UTXOs in
467    /// wallet-internal shielding transactions.
468    pub const MIN: Self = ConfirmationsPolicy {
469        trusted: NonZeroU32::MIN,
470        untrusted: NonZeroU32::MIN,
471        #[cfg(feature = "transparent-inputs")]
472        allow_zero_conf_shielding: true,
473    };
474
475    /// Constructs a new `ConfirmationsPolicy` with `trusted` and `untrusted` fields set to the
476    /// provided values.
477    ///
478    /// The number of confirmations required for trusted notes must be less than or equal to the
479    /// number of confirmations required for untrusted notes; this returns an error if this
480    /// invariant is violated.
481    ///
482    /// WARNING: This should only be used with great care to avoid problems of transaction
483    /// distinguishability; prefer [`ConfirmationsPolicy::default()`] instead.
484    pub fn new(
485        trusted: NonZeroU32,
486        untrusted: NonZeroU32,
487        #[cfg(feature = "transparent-inputs")] allow_zero_conf_shielding: bool,
488    ) -> Result<Self, ConfirmationsPolicyError> {
489        if trusted > untrusted {
490            Err(ConfirmationsPolicyError)
491        } else {
492            Ok(Self {
493                trusted,
494                untrusted,
495                #[cfg(feature = "transparent-inputs")]
496                allow_zero_conf_shielding,
497            })
498        }
499    }
500
501    /// Constructs a new `ConfirmationsPolicy` with `trusted` and `untrusted` fields both
502    /// set to `min_confirmations`.
503    ///
504    /// WARNING: This should only be used with great care to avoid problems of transaction
505    /// distinguishability; prefer [`ConfirmationsPolicy::default()`] instead.
506    pub fn new_symmetrical(
507        min_confirmations: NonZeroU32,
508        #[cfg(feature = "transparent-inputs")] allow_zero_conf_shielding: bool,
509    ) -> Self {
510        Self {
511            trusted: min_confirmations,
512            untrusted: min_confirmations,
513            #[cfg(feature = "transparent-inputs")]
514            allow_zero_conf_shielding,
515        }
516    }
517
518    /// Constructs a new `ConfirmationsPolicy` with `trusted` and `untrusted` fields set to the
519    /// provided values, which must both be nonzero. The number of trusted confirmations required
520    /// must be less than or equal to the number of untrusted confirmations required.
521    ///
522    /// # Panics
523    /// Panics if `trusted > untrusted` or either argument value is zero.
524    #[cfg(any(test, feature = "test-dependencies"))]
525    pub fn new_unchecked(
526        trusted: u32,
527        untrusted: u32,
528        #[cfg(feature = "transparent-inputs")] allow_zero_conf_shielding: bool,
529    ) -> Self {
530        Self::new(
531            NonZeroU32::new(trusted).expect("trusted must be nonzero"),
532            NonZeroU32::new(untrusted).expect("untrusted must be nonzero"),
533            #[cfg(feature = "transparent-inputs")]
534            allow_zero_conf_shielding,
535        )
536        .expect("trusted must be <= untrusted")
537    }
538
539    /// Constructs a new `ConfirmationsPolicy` with `trusted` and `untrusted` fields both
540    /// set to `min_confirmations`.
541    ///
542    /// # Panics
543    /// Panics if `min_confirmations == 0`
544    #[cfg(any(test, feature = "test-dependencies"))]
545    pub fn new_symmetrical_unchecked(
546        min_confirmations: u32,
547        #[cfg(feature = "transparent-inputs")] allow_zero_conf_shielding: bool,
548    ) -> Self {
549        Self::new_symmetrical(
550            NonZeroU32::new(min_confirmations).expect("min_confirmations must be nonzero"),
551            #[cfg(feature = "transparent-inputs")]
552            allow_zero_conf_shielding,
553        )
554    }
555
556    /// Returns the number of confirmations required before trusted notes may be spent.
557    ///
558    /// See [`ZIP 315`] for details.
559    ///
560    /// [`ZIP 315`]: https://zips.z.cash/zip-0315#trusted-and-untrusted-txos
561    pub fn trusted(&self) -> NonZeroU32 {
562        self.trusted
563    }
564
565    /// Returns the number of confirmations required before untrusted notes may be spent.
566    ///
567    /// See [`ZIP 315`] for details.
568    ///
569    /// [`ZIP 315`]: https://zips.z.cash/zip-0315#trusted-and-untrusted-txos
570    pub fn untrusted(&self) -> NonZeroU32 {
571        self.untrusted
572    }
573
574    /// Returns the shielded anchor height to use for a transaction targeting `target_height` under
575    /// this policy: `target_height` less the number of required trusted confirmations.
576    ///
577    /// This is the anchor used to interpret a proposal step that carries no explicit anchor height —
578    /// a purely transparent step, whose anchor is unused (see
579    /// [`crate::proposal::Step::anchor_height`]).
580    pub fn anchor_height(&self, target_height: TargetHeight) -> BlockHeight {
581        target_height.saturating_sub(u32::from(self.trusted()))
582    }
583
584    /// Returns this policy adjusted so that the shielded anchor for `target_height` lands on a
585    /// boundary of `grid`, or `None` if no boundary is reachable.
586    ///
587    /// A [ZIP 318] pool crossing is proved against a boundary block rather than the chain tip, so
588    /// that many wallets' crossings share a small set of anchors instead of each pinning a unique
589    /// recent block.
590    ///
591    /// Bucketing is expressed as a RAISED CONFIRMATION REQUIREMENT rather than as a separately
592    /// lowered anchor, because the anchor and the bound on which notes may be spent are the same
593    /// quantity: `target_height` less the required confirmations. Moving that one number moves
594    /// both together, which is what makes it impossible to select a note having no witness at the
595    /// bucketed anchor. Lowering the anchor on its own would leave two numbers free to drift.
596    ///
597    /// The untrusted requirement is raised to match when it would otherwise fall below the trusted
598    /// one, preserving this type's `trusted <= untrusted` invariant.
599    ///
600    /// The boundary chosen is the one BEFORE the most recent one — an anchor age of 1, the
601    /// smallest ZIP 318 admits. Anchoring to the most recent boundary would be an age of 0, which
602    /// no migration transfer uses.
603    ///
604    /// `activation_height` is the activation of the pool being crossed into; the chosen boundary
605    /// must lie strictly above it.
606    ///
607    /// Returns `None` when no usable boundary is reachable: when the ordinary anchor lies below the
608    /// first boundary after `activation_height`, or where the required confirmations would reach
609    /// back past the genesis block. Reporting that as "unable to bucket" is what lets a caller fall
610    /// back, rather than build a proposal that is bound to fail.
611    ///
612    /// [ZIP 318]: https://zips.z.cash/zip-0318
613    pub fn bucketed(
614        &self,
615        grid: AnchorGrid,
616        target_height: TargetHeight,
617        activation_height: BlockHeight,
618    ) -> Option<Self> {
619        // ZIP 318 draws an anchor of AGE `a` in `[1, ANCHOR_AGE_CAP]` boundaries behind the most
620        // recent one, so the chosen boundary is always strictly below it. Age 1 is taken here: the
621        // newest admissible boundary, and the modal age under the migration's `Geometric(1/2)`
622        // draw. Anchoring to the most recent boundary instead would be an age of 0, which no
623        // migration transfer ever uses.
624        let most_recent = grid.boundary_at_or_below(self.anchor_height(target_height));
625        let boundary = grid.boundary_before(most_recent)?;
626
627        // The boundary must lie strictly above the activation of the pool being crossed into.
628        // Rounding down can otherwise land on a PRE-ACTIVATION boundary in the window between
629        // activation and the first boundary after it, and ZIP 318's candidate set contains no such
630        // height — anchoring there would be distinguishable rather than shared. Height zero is
631        // excluded by the same bound, its note commitment tree being empty.
632        if boundary <= activation_height {
633            return None;
634        }
635        let bucketed = u32::from(target_height).checked_sub(u32::from(boundary))?;
636        let trusted = NonZeroU32::new(bucketed)?;
637        Self::new(
638            trusted,
639            core::cmp::max(self.untrusted(), trusted),
640            #[cfg(feature = "transparent-inputs")]
641            self.allow_zero_conf_shielding(),
642        )
643        .ok()
644    }
645
646    /// Returns whether or not transparent inputs may be spent with zero confirmations in shielding
647    /// transactions.
648    #[cfg(feature = "transparent-inputs")]
649    pub fn allow_zero_conf_shielding(&self) -> bool {
650        self.allow_zero_conf_shielding
651    }
652
653    /// Returns the number of confirmations until a transaction output is considered spendable,
654    /// given information about the output and the inputs to the transaction that produced it.
655    ///
656    /// # Parameters
657    /// - `target_height`: The minimum height at which the output will be potentially spent.
658    /// - `pool_type`: The Zcash pool that the output was received into.
659    /// - `receiving_key_scope`: The ZIP 32 [`Scope`] of the key that received the output,
660    ///   or `None` if the scope is unknown or corresponds to the ephemeral transparent
661    ///   key scope.
662    /// - `mined_height`: The block height at which the transaction that produced the output was
663    ///   mined, if any.
664    /// - `tx_trusted`: A boolean flag indicating whether the received transaction has been
665    ///   explicitly marked as trusted by the user.
666    /// - `max_shielding_input_height`: For outputs that are the result of wallet-internal
667    ///   shielding transactions, the maximum height at which any transparent input to that
668    ///   transaction was received.
669    /// - `tx_shielding_inputs_trusted`: For outputs that are the result of wallet-internal
670    ///   shielding transactions, a flag indicating whether all transparent inputs to that
671    ///   transaction have been explicitly marked as trusted by the user.
672    #[allow(clippy::too_many_arguments)]
673    pub fn confirmations_until_spendable(
674        &self,
675        target_height: TargetHeight,
676        pool_type: PoolType,
677        receiving_key_scope: Option<Scope>,
678        mined_height: Option<BlockHeight>,
679        tx_trusted: bool,
680        max_shielding_input_height: Option<BlockHeight>,
681        tx_shielding_inputs_trusted: bool,
682    ) -> u32 {
683        // Trusted outputs of transactions mined at heights greater than `trusted_height` will not
684        // be treated as spendable.
685        let trusted_height = target_height.saturating_sub(u32::from(self.trusted));
686        // Untrusted outputs of transactions mined at heights greater than `untrusted_height` will
687        // not be treated as spendable.
688        let untrusted_height = target_height.saturating_sub(u32::from(self.untrusted));
689
690        // Calculate the possible options for confirmations.
691        // - If the output's tx is unmined, we are constantly waiting for the maximum number of
692        //   confirmations.
693        // - If the output's tx is mined, the required number of confirmations decreases to a floor
694        //   of zero.
695        let confs_for_trusted =
696            mined_height.map_or(u32::from(self.trusted), |h| h - trusted_height);
697        let confs_for_untrusted =
698            mined_height.map_or(u32::from(self.untrusted), |h| h - untrusted_height);
699        match pool_type {
700            PoolType::Transparent => {
701                #[cfg(feature = "transparent-inputs")]
702                let zc_shielding = self.allow_zero_conf_shielding;
703                #[cfg(not(feature = "transparent-inputs"))]
704                let zc_shielding = false;
705
706                if zc_shielding {
707                    0
708                } else if tx_trusted || receiving_key_scope == Some(Scope::Internal) {
709                    confs_for_trusted
710                } else {
711                    confs_for_untrusted
712                }
713            }
714            PoolType::Shielded(_) => {
715                if tx_trusted {
716                    confs_for_trusted
717                } else if receiving_key_scope == Some(Scope::Internal) {
718                    // If the note was the output of a shielding transaction, we use the mined
719                    // height of the transparent source funds & their trust status instead of the
720                    // height at which the shielding transaction was mined.
721                    if let Some(h) = max_shielding_input_height {
722                        if tx_shielding_inputs_trusted {
723                            h - trusted_height
724                        } else {
725                            h - untrusted_height
726                        }
727                    } else {
728                        confs_for_trusted
729                    }
730                } else {
731                    confs_for_untrusted
732                }
733            }
734        }
735    }
736}
737
738/// Select transaction inputs, compute fees, and construct a proposal for a transaction or series
739/// of transactions that can then be authorized and made ready for submission to the network with
740/// [`create_proposed_transactions`].
741///
742/// When `lock_inputs` is `Some(request)`, every input selected by the returned proposal is
743/// locked via [`OutputLockStore::lock_outputs`] on behalf of the request's [`LockOwner`], with an
744/// expiry height of `target_height + request.for_blocks()`, so that the inputs are excluded from
745/// selection by subsequent proposals until that height is reached (or until they are explicitly
746/// released; see below). When it is `None`, no locking is performed.
747///
748/// This is the owner- and height-based generalization of the `lock_notes: bool` parameter
749/// originally proposed in [zcash/librustzcash#2161].
750///
751/// # Concurrency
752///
753/// Locking is how overlapping proposals for the same account are kept from selecting the same
754/// inputs. If a concurrent caller (a different [`LockOwner`]) has already locked one of the
755/// inputs this proposal selected (a check-then-lock race resolved at the storage layer), locking
756/// fails and the error surfaces as [`ProposalError::InputsLocked`] identifying the conflicting
757/// output; the losing caller should treat this as "the account is busy" and retry. Re-locking
758/// under the SAME owner succeeds (an idempotent acquire/extend), so a flow that crashed after
759/// locking may safely retry with its original owner token. A caller that abandons a proposal
760/// whose inputs it locked should release them with [`unlock_proposal_inputs`] under the same
761/// owner; locks are otherwise cleared automatically when the inputs are recorded as spent by
762/// [`WalletWrite::store_transactions_to_be_sent`], when their expiry height is reached, or via
763/// [`OutputLockStore::clear_locked_outputs`].
764///
765/// Note that expiry re-opens the race the lock exists to prevent: if building and proving the
766/// transaction takes longer than the requested lock window, the lock expires and a concurrent
767/// proposal may select and spend the same inputs. Choose the window conservatively with respect
768/// to the worst-case time between proposal creation and transaction storage.
769///
770/// [`LockOwner`]: crate::wallet::LockOwner
771/// [`OutputLockStore::lock_outputs`]: crate::data_api::OutputLockStore::lock_outputs
772/// [`WalletWrite::store_transactions_to_be_sent`]: crate::data_api::WalletWrite::store_transactions_to_be_sent
773/// [`OutputLockStore::clear_locked_outputs`]: crate::data_api::OutputLockStore::clear_locked_outputs
774/// [zcash/librustzcash#2161]: https://github.com/zcash/librustzcash/issues/2161
775#[allow(clippy::too_many_arguments)]
776#[allow(clippy::type_complexity)]
777pub fn propose_transfer<DbT, ParamsT, InputsT, ChangeT, CommitmentTreeErrT>(
778    wallet_db: &mut DbT,
779    params: &ParamsT,
780    spend_from_account: <DbT as InputSource>::AccountId,
781    input_selector: &InputsT,
782    change_strategy: &ChangeT,
783    request: zip321::TransactionRequest,
784    confirmations_policy: ConfirmationsPolicy,
785    spend_policy: &input_selection::SpendPolicy,
786    lock_inputs: Option<LockRequest>,
787    proposed_version: Option<TxVersion>,
788) -> Result<
789    Proposal<ChangeT::FeeRule, <DbT as InputSource>::NoteRef>,
790    ProposeTransferErrT<DbT, CommitmentTreeErrT, InputsT, ChangeT>,
791>
792where
793    DbT: WalletWrite + InputSource<Error = <DbT as WalletRead>::Error>,
794    <DbT as InputSource>::NoteRef: Copy + Eq + Ord,
795    ParamsT: consensus::Parameters + Clone,
796    InputsT: InputSelector<InputSource = DbT>,
797    ChangeT: ChangeStrategy<MetaSource = DbT>,
798{
799    // Using the trusted confirmations results in an anchor_height that will
800    // include the maximum number of notes being selected, and we can filter
801    // later based on the input source (whether it's trusted or not) and the
802    // number of confirmations
803    let maybe_intial_heights = wallet_db
804        .get_target_and_anchor_heights(confirmations_policy.trusted)
805        .map_err(InputSelectorError::DataSource)?;
806    let (target_height, anchor_height) =
807        maybe_intial_heights.ok_or_else(|| InputSelectorError::SyncRequired)?;
808
809    // The ZIP 318 parameters in force for THIS wallet. The anchor bucket grid must come from the
810    // wallet rather than the network defaults: the wallet is the side that retains the
811    // checkpoints, so its grid is the only one a crossing can actually be proved against, and
812    // every decision below that depends on the grid consults this one value.
813    let zip318 = wallet_db.pool_migration_params(params);
814
815    // A payment of a canonical ZIP 318 denomination across the Orchard turnstile is proposed
816    // against a BUCKETED anchor and funded from a single Orchard note, so that the resulting
817    // transaction is indistinguishable from a ZIP 318 migration transfer (see
818    // `Step::is_canonical_crossing`). Whether that is achievable is decided here, before any
819    // proposal is kept, rather than discovered by failure: the common miss is not insufficient
820    // funds but a SUCCESSFUL multi-note selection, which funds perfectly well yet is not a
821    // canonical crossing. Falling back only on insufficient funds would leave such a transaction
822    // carrying a bucketed anchor — up to one interval of extra confirmations on its inputs — while
823    // still being built padded, which is worse than never having tried.
824    //
825    // Selection is restricted to the Orchard pool for the attempt. That is not merely a
826    // preference: a canonical crossing admits no Ironwood spends, and the ordinary selector
827    // prefers to avoid crossing pools, so for an Ironwood-destined payment it reaches for Ironwood
828    // notes whenever the wallet holds them. Without the restriction, a user migrating their own
829    // funds by sending themselves canonical amounts would stall as soon as the first crossing
830    // produced Ironwood notes: every later payment would be funded from Ironwood, crossing
831    // nothing, and no further value would ever leave the Orchard pool.
832    //
833    // The canonical fee for the shape, under the parameters and target height this transaction
834    // will actually be built against. The STANDARD ZIP 317 rule is asked, not the caller's: a
835    // proposal built on a fixed non-standard rule would otherwise be compared against its own fee
836    // and always agree, which is precisely the case ZIP 318 forbids.
837    #[cfg(feature = "orchard")]
838    let canonical_fee = crate::fees::canonical_crossing_fee(params, target_height.into()).ok();
839
840    // The whole attempt is Orchard-gated: without that feature there is no Ironwood pool to cross
841    // into, so there is no canonical crossing to construct.
842    #[cfg(feature = "orchard")]
843    let bucketed_policy = canonical_crossing_candidate(params, &zip318, &request, target_height)
844        .then(|| params.activation_height(NetworkUpgrade::Nu6_3))
845        .flatten()
846        .and_then(|activation| {
847            confirmations_policy.bucketed(zip318.anchor_grid(), target_height, activation)
848        })
849        // A canonical crossing spends an Orchard note; if the caller forbids that, there is no
850        // canonical path to attempt.
851        .filter(|_| spend_policy.permits_shielded(ShieldedPool::Orchard));
852
853    // An anchor must be COMPUTABLE at the chosen boundary, not merely arithmetically valid: the
854    // data source must be able to produce the Orchard tree root there. A wallet that scanned
855    // past NU6.3 activation before boundary checkpointing was repaired is permanently missing
856    // the boundaries whose blocks carried no shielded outputs, and the hole cannot be backfilled
857    // from local state. Abandoning the attempt here degrades to an ordinary crossing — the same
858    // fallback taken when no sufficiently-old note exists — rather than proposing a transaction
859    // whose build must fail with `AnchorNotFound`. Under repaired retention the miss is
860    // temporary: freshly scanned boundaries rotate into the age-1 position within about a grid
861    // interval of upgrading.
862    #[cfg(feature = "orchard")]
863    let bucketed_policy = match bucketed_policy {
864        Some(policy) => wallet_db
865            .anchor_computable(ShieldedPool::Orchard, policy.anchor_height(target_height))
866            .map_err(|e| Error::from(InputSelectorError::DataSource(e)))?
867            .then_some(policy),
868        None => None,
869    };
870
871    #[cfg(feature = "orchard")]
872    let canonical_attempt = bucketed_policy.map(|bucketed_policy| {
873        // Single-note funding is PREFERRED, not merely hoped for: a migration transfer
874        // spends exactly one note, and accumulation reaches the target through several
875        // small notes whenever the oldest notes are small — funding perfectly well while
876        // losing the canonical shape. Preferring the oldest single covering note makes the
877        // canonical outcome the common one; when no single note covers the payment, the
878        // fallback accumulation funds it and the shape check below discards the attempt,
879        // exactly as before.
880        let orchard_only = input_selection::SpendPolicy::shielded_pools([ShieldedPool::Orchard])
881            .with_locked_input_policy(spend_policy.locked_input_policy().clone())
882            .with_note_selection(input_selection::NoteSelection::PreferSingle);
883
884        input_selector.propose_transaction(
885            params,
886            wallet_db,
887            target_height,
888            bucketed_policy.anchor_height(target_height),
889            &zip318,
890            bucketed_policy,
891            spend_from_account,
892            request.clone(),
893            change_strategy,
894            &orchard_only,
895            proposed_version,
896        )
897    });
898
899    // Only two outcomes justify falling back to an ordinary proposal: the wallet cannot fund the
900    // payment under the stricter policy, or it funded one that is not in fact canonical. Every
901    // other error — data source, selection, change computation, proposal construction, address
902    // parsing, sync state — describes a condition the ordinary attempt would meet just the same,
903    // so suppressing it here would replace a precise diagnosis with a silently different
904    // transaction, or with an identical failure reported from a second, wasted selection pass.
905    #[cfg(feature = "orchard")]
906    let canonical_proposal = match canonical_attempt {
907        Some(Ok(proposal)) => {
908            // The authoritative test is the one the builder will also apply, so that there is
909            // exactly one definition of "canonical" and the two cannot disagree. A proposal that
910            // funded successfully but not from a single Orchard note fails it here and is
911            // discarded, costing a selection pass but no extra confirmations.
912            let is_canonical = proposal.steps().len() == 1
913                && canonical_fee.is_some_and(|fee| {
914                    proposal.steps().first().is_canonical_crossing(&zip318, fee)
915                });
916            is_canonical.then_some(proposal)
917        }
918        Some(Err(InputSelectorError::InsufficientFunds { .. })) | None => None,
919        Some(Err(other)) => return Err(other.into()),
920    };
921
922    #[cfg(not(feature = "orchard"))]
923    let canonical_proposal = None;
924
925    let proposal = match canonical_proposal {
926        Some(proposal) => proposal,
927        None => input_selector.propose_transaction(
928            params,
929            wallet_db,
930            target_height,
931            anchor_height,
932            &zip318,
933            confirmations_policy,
934            spend_from_account,
935            request,
936            change_strategy,
937            spend_policy,
938            proposed_version,
939        )?,
940    };
941    proposal.check_transaction_size()?;
942    if let Some(request) = lock_inputs {
943        let lock_expiry_height = target_height + request.for_blocks();
944        lock_proposal_inputs(wallet_db, &proposal, request.owner(), lock_expiry_height)?;
945    }
946
947    // Record the requested version on the proposal so that it is carried through to transaction
948    // building; when `None`, building falls back to the version implied by the target height.
949    Ok(proposal.with_proposed_version(proposed_version))
950}
951
952/// Returns whether `request` is worth attempting as a canonical ZIP 318 crossing: a single
953/// payment, of a canonical denomination, at a height where the Ironwood pool exists.
954///
955/// This is a cheap gate, not a decision. It deliberately does not resolve the recipient address to
956/// an output pool, nor consider which notes are available: those depend on input selection, which
957/// has not run yet. The authoritative test is [`Step::is_canonical_crossing`] applied to the
958/// resulting proposal, so a request that passes here but produces a non-canonical proposal is
959/// simply discarded. Keeping the gate loose avoids duplicating address-resolution logic that would
960/// then have to be kept in step with input selection.
961#[cfg(feature = "orchard")]
962fn canonical_crossing_candidate<ParamsT: consensus::Parameters>(
963    params: &ParamsT,
964    zip318: &PoolMigrationParams,
965    request: &zip321::TransactionRequest,
966    target_height: TargetHeight,
967) -> bool {
968    params.is_nu_active(NetworkUpgrade::Nu6_3, target_height.into())
969        && match request.payments().values().collect::<Vec<_>>()[..] {
970            [payment] => payment
971                .amount()
972                .is_some_and(|amount| zip318.is_canonical_denomination(amount)),
973            _ => false,
974        }
975}
976
977/// Returns whether `step` will be built as a canonical ZIP 318 crossing, under the ZIP 318
978/// parameters `wallet_db` reports and the fee the canonical shape costs at `target_height`.
979///
980/// Shared by every path that builds a proposal, so that each asks the same question of the same
981/// data. `create_pczt_from_proposal` in particular applies its expiry override after the builder
982/// has run, and so cannot rely on the check inside `build_proposed_transaction`.
983#[cfg(feature = "orchard")]
984fn step_is_canonical_crossing<DbT, ParamsT, N>(
985    wallet_db: &DbT,
986    params: &ParamsT,
987    step: &Step<N>,
988    target_height: TargetHeight,
989) -> bool
990where
991    DbT: WalletRead,
992    ParamsT: consensus::Parameters,
993{
994    crate::fees::canonical_crossing_fee(params, target_height.into())
995        .is_ok_and(|fee| step.is_canonical_crossing(&wallet_db.pool_migration_params(params), fee))
996}
997
998/// Proposes making a payment to the specified address from the given account.
999///
1000/// Returns the proposal, which may then be executed using [`create_proposed_transactions`].
1001/// Depending upon the recipient address, more than one transaction may be constructed
1002/// in the execution of the returned proposal.
1003///
1004/// This method uses the basic [`GreedyInputSelector`] for input selection.
1005///
1006/// Parameters:
1007/// * `wallet_db`: A read/write reference to the wallet database.
1008/// * `params`: Consensus parameters.
1009/// * `fee_rule`: The fee rule to use in creating the transaction.
1010/// * `spend_from_account`: The unified account that controls the funds that will be spent
1011///   in the resulting transaction. This procedure will return an error if the
1012///   account ID does not correspond to an account known to the wallet.
1013/// * `confirmations_policy`: The minimum number of confirmations that a previously
1014///   received note must have in the blockchain in order to be considered for being
1015///   spent. A value of 10 confirmations is recommended and 0-conf transactions are
1016///   not supported.
1017/// * `to`: The address to which `amount` will be paid.
1018/// * `amount`: The amount to send.
1019/// * `memo`: A memo to be included in the output to the recipient.
1020/// * `change_memo`: A memo to be included in any change output that is created.
1021/// * `fallback_change_pool`: The shielded pool to which change should be sent if
1022///   automatic change pool determination fails.
1023/// * `lock_inputs`: When `Some(request)`, the inputs selected by the proposal are locked on
1024///   behalf of the request's owner until `target_height + request.for_blocks()` to prevent
1025///   concurrent proposals from selecting them; when `None`, no locking is performed. See
1026///   [`propose_transfer`] for the full semantics and concurrency behavior.
1027#[allow(clippy::too_many_arguments)]
1028#[allow(clippy::type_complexity)]
1029pub fn propose_standard_transfer_to_address<DbT, ParamsT, CommitmentTreeErrT>(
1030    wallet_db: &mut DbT,
1031    params: &ParamsT,
1032    fee_rule: StandardFeeRule,
1033    spend_from_account: <DbT as InputSource>::AccountId,
1034    confirmations_policy: ConfirmationsPolicy,
1035    to: &Address,
1036    amount: Zatoshis,
1037    memo: Option<MemoBytes>,
1038    change_memo: Option<MemoBytes>,
1039    fallback_change_pool: ShieldedPool,
1040    lock_inputs: Option<LockRequest>,
1041    proposed_version: Option<TxVersion>,
1042) -> Result<
1043    Proposal<StandardFeeRule, DbT::NoteRef>,
1044    ProposeTransferErrT<
1045        DbT,
1046        CommitmentTreeErrT,
1047        GreedyInputSelector<DbT>,
1048        SingleOutputChangeStrategy<DbT>,
1049    >,
1050>
1051where
1052    ParamsT: consensus::Parameters + Clone,
1053    DbT: InputSource,
1054    DbT: WalletWrite,
1055    DbT: WalletRead<Error = <DbT as InputSource>::Error, AccountId = <DbT as InputSource>::AccountId>,
1056    DbT::NoteRef: Copy + Eq + Ord,
1057{
1058    let request = zip321::TransactionRequest::new(vec![
1059        Payment::new(
1060            to.to_zcash_address(params),
1061            Some(amount),
1062            memo,
1063            None,
1064            None,
1065            vec![],
1066        )
1067        .map_err(Error::Payment)?,
1068    ])
1069    .expect(
1070        "It should not be possible for this to violate ZIP 321 request construction invariants.",
1071    );
1072
1073    let input_selector = GreedyInputSelector::<DbT>::new();
1074    let change_strategy = SingleOutputChangeStrategy::<DbT>::new(
1075        fee_rule,
1076        change_memo,
1077        fallback_change_pool,
1078        DustOutputPolicy::default(),
1079    );
1080
1081    propose_transfer(
1082        wallet_db,
1083        params,
1084        spend_from_account,
1085        &input_selector,
1086        &change_strategy,
1087        request,
1088        confirmations_policy,
1089        &input_selection::SpendPolicy::default(),
1090        lock_inputs,
1091        proposed_version,
1092    )
1093}
1094
1095/// Proposes spending the maximum available value from the given shielded pools, sending it
1096/// to a single recipient.
1097///
1098/// Returns the proposal, which may then be executed using [`create_proposed_transactions`].
1099/// Depending upon the recipient address, more than one transaction may be constructed in the
1100/// execution of the returned proposal.
1101///
1102/// Unlike [`propose_transfer`], the caller does not choose an amount. The value sent is
1103/// whatever remains once the fee for the selected inputs is covered, and that fee is not
1104/// knowable in advance because it depends on which inputs the selector picks. This is the
1105/// reason to prefer this function over computing "balance minus fee" in the caller: only
1106/// input selection knows the fee, so only it can leave nothing behind.
1107///
1108/// The wallet must have been scanned far enough to establish target and anchor heights;
1109/// otherwise [`InputSelectorError::SyncRequired`] is returned.
1110///
1111/// Parameters:
1112/// * `wallet_db`: A read/write reference to the wallet database.
1113/// * `params`: Consensus parameters.
1114/// * `spend_from_account`: The unified account that controls the funds that will be spent
1115///   in the resulting transaction. This procedure will return an error if the account ID
1116///   does not correspond to an account known to the wallet.
1117/// * `spend_pools`: The shielded pools that may be drawn upon. Funds in any pool outside
1118///   this set are left untouched, so this is what makes the function usable for moving a
1119///   single pool rather than emptying the account. Transparent funds are never selected.
1120/// * `fee_rule`: The fee rule to use in creating the transaction.
1121/// * `recipient`: The address to which the spendable value will be paid.
1122/// * `memo`: A memo to be included in the output to the recipient. Supplying a memo for a
1123///   recipient that cannot receive one returns [`zip321::PaymentError::TransparentMemo`].
1124/// * `mode`: How to treat funds in `spend_pools` that are not currently spendable, for
1125///   example because a note lacks confirmations or its witness is not yet available.
1126///   [`MaxSpendMode::MaxSpendable`] skips such notes and proposes a transaction spending
1127///   the rest; [`MaxSpendMode::Everything`] returns an error instead, so that a caller who
1128///   needs the pools genuinely emptied cannot be handed a partial result that looks like a
1129///   complete one.
1130/// * `confirmations_policy`: The minimum number of confirmations that a previously
1131///   received note must have in the blockchain in order to be considered for being
1132///   spent. A value of 10 confirmations is recommended and 0-conf transactions are
1133///   not supported.
1134/// * `locked_input_policy`: Whether a locked note may be drawn upon to reach the requested
1135///   amount, exactly as [`input_selection::SpendPolicy::locked_input_policy`] does for
1136///   [`propose_transfer`]; pass `&LockedInputPolicy::Exclude` (its default) to never select
1137///   a locked note.
1138/// * `lock_inputs`: When `Some(request)`, the inputs selected by the proposal are locked on
1139///   behalf of the request's owner until `target_height + request.for_blocks()` to prevent
1140///   concurrent proposals from selecting them; when `None`, no locking is performed. See
1141///   [`propose_transfer`] for the full semantics and concurrency behavior.
1142#[allow(clippy::too_many_arguments)]
1143#[allow(clippy::type_complexity)]
1144pub fn propose_send_max_transfer<DbT, ParamsT, FeeRuleT, CommitmentTreeErrT>(
1145    wallet_db: &mut DbT,
1146    params: &ParamsT,
1147    spend_from_account: <DbT as InputSource>::AccountId,
1148    spend_pools: &[ShieldedPool],
1149    fee_rule: &FeeRuleT,
1150    recipient: ZcashAddress,
1151    memo: Option<MemoBytes>,
1152    mode: MaxSpendMode,
1153    confirmations_policy: ConfirmationsPolicy,
1154    locked_input_policy: &input_selection::LockedInputPolicy,
1155    lock_inputs: Option<LockRequest>,
1156) -> Result<
1157    Proposal<FeeRuleT, <DbT as InputSource>::NoteRef>,
1158    ProposeSendMaxErrT<DbT, CommitmentTreeErrT, FeeRuleT>,
1159>
1160where
1161    DbT: WalletWrite + InputSource<Error = <DbT as WalletRead>::Error>,
1162    <DbT as InputSource>::NoteRef: Copy + Eq + Ord,
1163    ParamsT: consensus::Parameters + Clone,
1164    FeeRuleT: FeeRule + Clone,
1165{
1166    let (target_height, anchor_height) = wallet_db
1167        .get_target_and_anchor_heights(confirmations_policy.trusted())
1168        .map_err(|e| Error::from(InputSelectorError::DataSource(e)))?
1169        .ok_or_else(|| Error::from(InputSelectorError::SyncRequired))?;
1170
1171    if memo.is_some() && !recipient.can_receive_memo() {
1172        return Err(Error::Payment(zip321::PaymentError::TransparentMemo));
1173    }
1174
1175    let proposal = propose_send_max(
1176        params,
1177        wallet_db,
1178        fee_rule,
1179        spend_from_account,
1180        spend_pools,
1181        target_height,
1182        anchor_height,
1183        mode,
1184        confirmations_policy,
1185        recipient,
1186        memo,
1187        locked_input_policy,
1188    )?;
1189
1190    if let Some(request) = lock_inputs {
1191        let lock_expiry_height = target_height + request.for_blocks();
1192        lock_proposal_inputs(wallet_db, &proposal, request.owner(), lock_expiry_height)?;
1193    }
1194
1195    Ok(proposal)
1196}
1197
1198/// Constructs a proposal to shield all of the funds belonging to the provided set of
1199/// addresses.
1200///
1201/// The `output_filter` parameter controls which transparent outputs are eligible for
1202/// inclusion in the proposal. See [`CoinbaseFilter`] for details.
1203///
1204/// When `lock_inputs` is `Some(request)`, the inputs selected by the proposal are locked on
1205/// behalf of the request's owner until `target_height + request.for_blocks()` to prevent
1206/// concurrent proposals from selecting them; when `None`, no locking is performed. See
1207/// [`propose_transfer`] for the full semantics and concurrency behavior.
1208#[cfg(feature = "transparent-inputs")]
1209#[allow(clippy::too_many_arguments)]
1210#[allow(clippy::type_complexity)]
1211pub fn propose_shielding<DbT, ParamsT, InputsT, ChangeT, CommitmentTreeErrT>(
1212    wallet_db: &mut DbT,
1213    params: &ParamsT,
1214    input_selector: &InputsT,
1215    change_strategy: &ChangeT,
1216    shielding_threshold: Zatoshis,
1217    from_addrs: &[TransparentAddress],
1218    to_account: <DbT as InputSource>::AccountId,
1219    confirmations_policy: ConfirmationsPolicy,
1220    output_filter: CoinbaseFilter,
1221    lock_inputs: Option<LockRequest>,
1222) -> Result<
1223    Proposal<ChangeT::FeeRule, Infallible>,
1224    ProposeShieldingErrT<DbT, CommitmentTreeErrT, InputsT, ChangeT>,
1225>
1226where
1227    ParamsT: consensus::Parameters,
1228    DbT: WalletWrite + InputSource<Error = <DbT as WalletRead>::Error>,
1229    InputsT: ShieldingSelector<InputSource = DbT>,
1230    ChangeT: ChangeStrategy<MetaSource = DbT>,
1231{
1232    let (target_height, anchor_height) = wallet_db
1233        .get_target_and_anchor_heights(confirmations_policy.trusted)
1234        .map_err(|e| Error::from(InputSelectorError::DataSource(e)))?
1235        .ok_or_else(|| Error::from(InputSelectorError::SyncRequired))?;
1236
1237    let proposal = input_selector
1238        .propose_shielding(
1239            params,
1240            wallet_db,
1241            change_strategy,
1242            shielding_threshold,
1243            from_addrs,
1244            to_account,
1245            target_height,
1246            anchor_height,
1247            &wallet_db.pool_migration_params(params),
1248            confirmations_policy,
1249            output_filter,
1250        )
1251        .map_err(Error::from)?;
1252
1253    proposal.check_transaction_size()?;
1254
1255    if let Some(request) = lock_inputs {
1256        let lock_expiry_height = target_height + request.for_blocks();
1257        lock_proposal_inputs(wallet_db, &proposal, request.owner(), lock_expiry_height)?;
1258    }
1259
1260    Ok(proposal)
1261}
1262
1263/// Errors that may be generated in construction of proposals for shielding coinbase
1264/// transparent outputs to an arbitrary shielded recipient via
1265/// [`propose_shielding_coinbase`].
1266#[cfg(feature = "transparent-inputs")]
1267pub type ProposeShieldingCoinbaseErrT<DbT, CommitmentTreeErrT, InputsT, FeeRuleT> = Error<
1268    <DbT as WalletRead>::Error,
1269    CommitmentTreeErrT,
1270    <InputsT as ShieldingSelector>::Error,
1271    <FeeRuleT as FeeRule>::Error,
1272    <FeeRuleT as FeeRule>::Error,
1273    Infallible,
1274>;
1275
1276/// Constructs a proposal to shield one or more coinbase transparent outputs to an
1277/// arbitrary shielded recipient.
1278///
1279/// Unlike [`propose_shielding`], this method:
1280///
1281/// - Restricts input selection to coinbase outputs only. The restriction is enforced
1282///   at the API boundary; it cannot be overridden by callers. Coinbase outputs have
1283///   no prior transparent transaction graph, which is what makes it acceptable to
1284///   send them to an arbitrary shielded recipient.
1285/// - Requires `to_address` to be a shielded address (Sapling, or a Unified Address
1286///   with a shielded receiver). Transparent and TEX destinations are rejected with
1287///   [`ProposalError::ShieldingRequiresShieldedRecipient`]. The address may belong
1288///   to an account outside the caller's wallet.
1289/// - Accepts an optional `memo` to be attached to the shielded payment.
1290/// - Accepts an optional `limit` capping the number of transparent inputs to at
1291///   most `n`, keeping the highest-value UTXOs (with a stable tiebreaker by
1292///   outpoint). `Some(0)` selects no inputs and therefore returns
1293///   [`InputSelectorError::InsufficientFunds`].
1294///
1295/// When `lock_inputs` is `Some(request)`, the coinbase inputs selected by the proposal are
1296/// locked on behalf of the request's owner until `target_height + request.for_blocks()` to
1297/// prevent concurrent proposals from selecting them; when `None`, no locking is performed. See
1298/// [`propose_transfer`] for the full semantics and concurrency behavior.
1299///
1300/// The resulting proposal carries an explicit ZIP-321 payment to `to_address` for
1301/// `input_total - fee`. **No change is produced**, in either the transparent or any
1302/// shielded pool: a shielded change output would let the recipient (or any chain
1303/// observer) learn the sender's total selected-coinbase value by summing the public
1304/// transparent input values and subtracting the visible payment amount.
1305///
1306/// [`InputSelectorError::InsufficientFunds`]: crate::data_api::wallet::input_selection::InputSelectorError::InsufficientFunds
1307/// [`ProposalError::ShieldingRequiresShieldedRecipient`]: crate::proposal::ProposalError::ShieldingRequiresShieldedRecipient
1308#[cfg(feature = "transparent-inputs")]
1309#[allow(clippy::too_many_arguments)]
1310#[allow(clippy::type_complexity)]
1311pub fn propose_shielding_coinbase<DbT, ParamsT, InputsT, FeeRuleT, CommitmentTreeErrT>(
1312    wallet_db: &mut DbT,
1313    params: &ParamsT,
1314    input_selector: &InputsT,
1315    fee_rule: &FeeRuleT,
1316    shielding_threshold: Zatoshis,
1317    from_addrs: &[TransparentAddress],
1318    to_address: ZcashAddress,
1319    memo: Option<MemoBytes>,
1320    limit: Option<usize>,
1321    lock_inputs: Option<LockRequest>,
1322) -> Result<
1323    Proposal<FeeRuleT, Infallible>,
1324    ProposeShieldingCoinbaseErrT<DbT, CommitmentTreeErrT, InputsT, FeeRuleT>,
1325>
1326where
1327    ParamsT: consensus::Parameters,
1328    DbT: WalletWrite + InputSource<Error = <DbT as WalletRead>::Error>,
1329    InputsT: ShieldingSelector<InputSource = DbT>,
1330    FeeRuleT: FeeRule + Clone,
1331{
1332    let (target_height, anchor_height) = wallet_db
1333        .get_target_and_anchor_heights(ConfirmationsPolicy::default().trusted)
1334        .map_err(|e| Error::from(InputSelectorError::DataSource(e)))?
1335        .ok_or_else(|| Error::from(InputSelectorError::SyncRequired))?;
1336
1337    let proposal = input_selector
1338        .propose_shielding_coinbase(
1339            params,
1340            wallet_db,
1341            fee_rule,
1342            shielding_threshold,
1343            from_addrs,
1344            to_address,
1345            memo,
1346            limit,
1347            target_height,
1348            anchor_height,
1349        )
1350        .map_err(Error::from)?;
1351
1352    if let Some(request) = lock_inputs {
1353        let lock_expiry_height = target_height + request.for_blocks();
1354        lock_proposal_inputs(wallet_db, &proposal, request.owner(), lock_expiry_height)?;
1355    }
1356
1357    Ok(proposal)
1358}
1359
1360struct StepResult<AccountId> {
1361    build_result: BuildResult,
1362    outputs: Vec<SentTransactionOutput<AccountId>>,
1363    fee_amount: Zatoshis,
1364    #[cfg(feature = "transparent-inputs")]
1365    utxos_spent: Vec<OutPoint>,
1366}
1367
1368/// A set of spending keys for an account, for use in execution of transaction proposals.
1369///
1370/// This consists of a [`UnifiedSpendingKey`], plus (if the `transparent-key-import` feature is
1371/// enabled) a set of standalone transparent spending keys corresponding to inputs being spent in a
1372/// transaction under construction.
1373///
1374/// When this value is dropped, the standalone transparent spending keys it holds are
1375/// overwritten. Copies of those keys made elsewhere are not overwritten, because
1376/// [`secp256k1::SecretKey`] is `Copy`.
1377pub struct SpendingKeys {
1378    usk: UnifiedSpendingKey,
1379    #[cfg(feature = "transparent-key-import")]
1380    standalone_transparent_keys: HashMap<TransparentAddress, Vec<secp256k1::SecretKey>>,
1381}
1382
1383impl SpendingKeys {
1384    /// Constructs a new [`SpendingKeys`] value from its constituent parts.
1385    pub fn new(
1386        usk: UnifiedSpendingKey,
1387        #[cfg(feature = "transparent-key-import")] standalone_transparent_keys: HashMap<
1388            TransparentAddress,
1389            Vec<secp256k1::SecretKey>,
1390        >,
1391    ) -> Self {
1392        Self {
1393            usk,
1394            #[cfg(feature = "transparent-key-import")]
1395            standalone_transparent_keys,
1396        }
1397    }
1398
1399    /// Constructs a new [`SpendingKeys`] value from a [`UnifiedSpendingKey`],
1400    /// without standalone spending keys.
1401    pub fn from_unified_spending_key(usk: UnifiedSpendingKey) -> Self {
1402        Self {
1403            usk,
1404            #[cfg(feature = "transparent-key-import")]
1405            standalone_transparent_keys: HashMap::new(),
1406        }
1407    }
1408}
1409
1410#[cfg(feature = "transparent-key-import")]
1411impl Drop for SpendingKeys {
1412    fn drop(&mut self) {
1413        for secret_key in self.standalone_transparent_keys.values_mut().flatten() {
1414            secret_key.non_secure_erase();
1415        }
1416    }
1417}
1418
1419/// Construct, prove, and sign a transaction or series of transactions using the inputs supplied by
1420/// the given proposal, and persist it to the wallet database.
1421///
1422/// Returns the database identifier for each newly constructed transaction, or an error if
1423/// an error occurs in transaction construction, proving, or signing.
1424///
1425/// `rng` supplies the randomness used to construct, prove, and sign each transaction.
1426///
1427/// When evaluating multi-step proposals, only transparent outputs of any given step may be spent
1428/// in later steps; attempting to spend a shielded note (including change) output by an earlier
1429/// step is not supported, because the ultimate positions of those notes in the global note
1430/// commitment tree cannot be known until the transaction that produces those notes is mined,
1431/// and therefore the required spend proofs for such notes cannot be constructed.
1432///
1433/// `expiry_height`, when set, replaces the builder-derived expiry for every step's
1434/// transaction before it is built and signed.
1435///
1436/// A nonzero `expiry_height` below the proposal's
1437/// [`min_target_height`](Proposal::min_target_height) is rejected with
1438/// [`Error::ExpiryHeightBelowTargetHeight`]. An `expiry_height` of zero,
1439/// which disables expiry, is exempt from this check.
1440#[allow(clippy::too_many_arguments)]
1441#[allow(clippy::type_complexity)]
1442pub fn create_proposed_transactions<DbT, ParamsT, InputsErrT, FeeRuleT, ChangeErrT, N>(
1443    wallet_db: &mut DbT,
1444    params: &ParamsT,
1445    clock: &impl Clock,
1446    rng: &mut impl CryptoRng,
1447    spend_prover: &impl SpendProver,
1448    output_prover: &impl OutputProver,
1449    spending_keys: &SpendingKeys,
1450    ovk_policy: OvkPolicy,
1451    proposal: &Proposal<FeeRuleT, N>,
1452    expiry_height: Option<BlockHeight>,
1453) -> Result<NonEmpty<TxId>, CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>>
1454where
1455    DbT: WalletWrite + WalletCommitmentTrees,
1456    ParamsT: consensus::Parameters + Clone,
1457    FeeRuleT: FeeRule,
1458{
1459    // The transaction version is carried on the proposal, chosen when the proposal was
1460    // constructed; `None` builds at the version implied by the target height.
1461    let proposed_version = proposal.proposed_version();
1462
1463    // Defense-in-depth: reject oversized proposals at the build entry point, so a
1464    // proposal from a third-party input selector or a deserialized proposal that
1465    // bypassed the `propose_*` wrappers cannot reach the expensive proving path.
1466    proposal.check_transaction_size()?;
1467
1468    if let Some(expiry_height) = expiry_height {
1469        let min_target_height = BlockHeight::from(proposal.min_target_height());
1470        if expiry_height != consensus::H0 && expiry_height < min_target_height {
1471            return Err(Error::ExpiryHeightBelowTargetHeight {
1472                expiry_height,
1473                min_target_height,
1474            });
1475        }
1476    }
1477
1478    // The set of transparent `StepOutput`s available and unused from prior steps.
1479    // When a transparent `StepOutput` is created, it is added to the map. When it
1480    // is consumed, it is removed from the map.
1481    #[cfg(feature = "transparent-inputs")]
1482    let mut unused_transparent_outputs = HashMap::new();
1483
1484    let account_id = wallet_db
1485        .get_account_for_ufvk(&spending_keys.usk.to_unified_full_viewing_key())
1486        .map_err(Error::DataSource)?
1487        .ok_or(Error::KeyNotRecognized)?
1488        .id();
1489
1490    let now = clock.now();
1491
1492    let mut step_results = Vec::with_capacity(proposal.steps().len());
1493    for step in proposal.steps() {
1494        let step_result: StepResult<_> = create_proposed_transaction(
1495            wallet_db,
1496            params,
1497            &mut *rng,
1498            spend_prover,
1499            output_prover,
1500            spending_keys,
1501            account_id,
1502            ovk_policy.clone(),
1503            proposal.fee_rule(),
1504            proposal.min_target_height(),
1505            proposal.confirmations_policy(),
1506            &step_results,
1507            step,
1508            #[cfg(feature = "transparent-inputs")]
1509            &mut unused_transparent_outputs,
1510            proposed_version,
1511            expiry_height,
1512            now,
1513        )?;
1514        step_results.push((step, step_result));
1515    }
1516
1517    // Ephemeral outputs must be referenced exactly once.
1518    #[cfg(feature = "transparent-inputs")]
1519    for so in unused_transparent_outputs.into_keys() {
1520        if let StepOutputIndex::Change(i) = so.output_index() {
1521            // references have already been checked
1522            if step_results[so.step_index()].0.balance().proposed_change()[i].is_ephemeral() {
1523                return Err(ProposalError::EphemeralOutputLeftUnspent(so).into());
1524            }
1525        }
1526    }
1527
1528    let created = time::OffsetDateTime::from(now);
1529
1530    // Store the transactions only after creating all of them. This avoids undesired
1531    // retransmissions in case a transaction is stored and the creation of a subsequent
1532    // transaction fails.
1533    let mut transactions = Vec::with_capacity(step_results.len());
1534    let mut txids = Vec::with_capacity(step_results.len());
1535    #[allow(unused_variables)]
1536    for (_, step_result) in step_results.iter() {
1537        let tx = step_result.build_result.transaction();
1538        transactions.push(SentTransaction::new(
1539            tx,
1540            created,
1541            proposal.min_target_height(),
1542            account_id,
1543            &step_result.outputs,
1544            step_result.fee_amount,
1545            #[cfg(feature = "transparent-inputs")]
1546            &step_result.utxos_spent,
1547        ));
1548        txids.push(tx.txid());
1549    }
1550
1551    wallet_db
1552        .store_transactions_to_be_sent(&transactions)
1553        .map_err(Error::DataSource)?;
1554
1555    Ok(NonEmpty::from_vec(txids).expect("proposal.steps is NonEmpty"))
1556}
1557
1558/// A recipient of a shielded output under construction, awaiting the decrypted [`Note`]
1559/// that [`ShieldedBuildRecipient::into_recipient`] will attach to produce a [`Recipient`].
1560#[derive(Debug, Clone)]
1561enum ShieldedBuildRecipient<AccountId> {
1562    External {
1563        recipient_address: ZcashAddress,
1564        output_pool: PoolType,
1565    },
1566    InternalShielded {
1567        receiving_account: AccountId,
1568        external_address: Option<ZcashAddress>,
1569    },
1570}
1571
1572impl<AccountId> ShieldedBuildRecipient<AccountId> {
1573    fn into_recipient(self, note: impl FnOnce() -> Note) -> Recipient<AccountId> {
1574        match self {
1575            ShieldedBuildRecipient::External {
1576                recipient_address,
1577                output_pool,
1578            } => Recipient::External {
1579                recipient_address,
1580                output_pool,
1581            },
1582            ShieldedBuildRecipient::InternalShielded {
1583                receiving_account,
1584                external_address,
1585            } => Recipient::InternalShielded {
1586                receiving_account,
1587                external_address,
1588                note: Box::new(note()),
1589            },
1590        }
1591    }
1592}
1593
1594/// A recipient of a transparent output under construction, awaiting the [`OutPoint`] (for
1595/// ephemeral outputs only) that [`TransparentBuildRecipient::into_recipient`] will attach
1596/// to produce a [`Recipient`].
1597#[derive(Debug, Clone)]
1598enum TransparentBuildRecipient<AccountId> {
1599    External {
1600        recipient_address: ZcashAddress,
1601        output_pool: PoolType,
1602    },
1603    #[cfg(feature = "transparent-inputs")]
1604    EphemeralTransparent {
1605        receiving_account: AccountId,
1606        ephemeral_address: TransparentAddress,
1607    },
1608    #[cfg(feature = "transparent-inputs")]
1609    InternalTransparent {
1610        receiving_account: AccountId,
1611        recipient_address: TransparentAddress,
1612    },
1613    /// Never constructed. Present only so that `AccountId` remains a used type parameter
1614    /// when `transparent-inputs` is disabled, in which case the two variants above (the
1615    /// only ones that otherwise reference it) do not exist. The uninhabited
1616    /// [`core::convert::Infallible`] field lets every match on this type prove, rather than
1617    /// assert, that this arm is unreachable.
1618    #[cfg(not(feature = "transparent-inputs"))]
1619    #[doc(hidden)]
1620    #[allow(dead_code)]
1621    Unconstructible(
1622        core::marker::PhantomData<AccountId>,
1623        core::convert::Infallible,
1624    ),
1625}
1626
1627impl<AccountId> TransparentBuildRecipient<AccountId> {
1628    fn into_recipient(
1629        self,
1630        #[cfg(feature = "transparent-inputs")] outpoint: OutPoint,
1631    ) -> Recipient<AccountId> {
1632        match self {
1633            TransparentBuildRecipient::External {
1634                recipient_address,
1635                output_pool,
1636            } => Recipient::External {
1637                recipient_address,
1638                output_pool,
1639            },
1640            #[cfg(feature = "transparent-inputs")]
1641            TransparentBuildRecipient::EphemeralTransparent {
1642                receiving_account,
1643                ephemeral_address,
1644            } => Recipient::EphemeralTransparent {
1645                receiving_account,
1646                ephemeral_address,
1647                outpoint,
1648            },
1649            #[cfg(feature = "transparent-inputs")]
1650            TransparentBuildRecipient::InternalTransparent {
1651                receiving_account,
1652                recipient_address,
1653            } => Recipient::InternalTransparent {
1654                receiving_account,
1655                recipient_address,
1656            },
1657            #[cfg(not(feature = "transparent-inputs"))]
1658            TransparentBuildRecipient::Unconstructible(_, absurd) => match absurd {},
1659        }
1660    }
1661}
1662
1663#[allow(clippy::type_complexity)]
1664struct BuildState<P, AccountId> {
1665    #[cfg(feature = "transparent-inputs")]
1666    step_index: usize,
1667    builder: Builder<P, ()>,
1668    #[cfg(feature = "transparent-inputs")]
1669    transparent_input_addresses: HashMap<TransparentAddress, TransparentAddressMetadata>,
1670    #[cfg(feature = "orchard")]
1671    orchard_output_meta: Vec<(
1672        ShieldedBuildRecipient<AccountId>,
1673        Zatoshis,
1674        Option<MemoBytes>,
1675    )>,
1676    #[cfg(feature = "orchard")]
1677    ironwood_output_meta: Vec<(
1678        ShieldedBuildRecipient<AccountId>,
1679        Zatoshis,
1680        Option<MemoBytes>,
1681    )>,
1682    sapling_output_meta: Vec<(
1683        ShieldedBuildRecipient<AccountId>,
1684        Zatoshis,
1685        Option<MemoBytes>,
1686    )>,
1687    transparent_output_meta: Vec<(
1688        TransparentBuildRecipient<AccountId>,
1689        TransparentAddress,
1690        Zatoshis,
1691        StepOutputIndex,
1692    )>,
1693    #[cfg(feature = "transparent-inputs")]
1694    utxos_spent: Vec<OutPoint>,
1695}
1696
1697/// Enforces the ZIP 316 Revision 2 address expiration rules for a payment recipient: a
1698/// payment must not be made to an address that is known to have expired, and when the
1699/// recipient defines an expiry height, the transaction must carry a nonzero expiry
1700/// height no greater than it. All recipients of a multi-recipient transaction are
1701/// subject to these constraints.
1702///
1703/// An address that defines an expiry height is expired once the chain has advanced past
1704/// that height. `min_target_height` is the next chain tip, so the tip that expiry is
1705/// evaluated against is one block below it.
1706fn check_recipient_expiry(
1707    ua: &UnifiedAddress,
1708    min_target_height: TargetHeight,
1709    tx_expiry_height: Option<BlockHeight>,
1710    now: SystemTime,
1711) -> Result<(), AddressExpiryError> {
1712    if let Some(address_expiry) = ua.expiry_height() {
1713        let chain_height = min_target_height.saturating_sub(1);
1714        if chain_height > address_expiry {
1715            return Err(AddressExpiryError::HeightExpired {
1716                address_expiry,
1717                chain_height,
1718            });
1719        }
1720
1721        match tx_expiry_height {
1722            None => {
1723                return Err(AddressExpiryError::TransactionExpiryDisabled { address_expiry });
1724            }
1725            Some(transaction_expiry) if transaction_expiry > address_expiry => {
1726                return Err(AddressExpiryError::ExpiryHeightConflict {
1727                    address_expiry,
1728                    transaction_expiry,
1729                });
1730            }
1731            Some(_) => {}
1732        }
1733    }
1734
1735    if let Some(expiry_time) = ua.expiry_time() {
1736        // A clock before the Unix epoch reads as time zero, at which no address with
1737        // expiry-time metadata is expired.
1738        let now_secs = now
1739            .duration_since(SystemTime::UNIX_EPOCH)
1740            .map(|d| d.as_secs())
1741            .unwrap_or(0);
1742        if now_secs > expiry_time {
1743            return Err(AddressExpiryError::TimeExpired { expiry_time });
1744        }
1745    }
1746
1747    Ok(())
1748}
1749
1750// `unused_transparent_outputs` maps `StepOutput`s for transparent outputs
1751// that have not been consumed so far, to the corresponding pair of
1752// `TransparentAddress` and `Outpoint`.
1753#[allow(clippy::too_many_arguments)]
1754#[allow(clippy::type_complexity)]
1755fn build_proposed_transaction<DbT, ParamsT, InputsErrT, FeeRuleT, ChangeErrT, N>(
1756    wallet_db: &mut DbT,
1757    params: &ParamsT,
1758    ufvk: &UnifiedFullViewingKey,
1759    account_id: <DbT as WalletRead>::AccountId,
1760    ovk_policy: OvkPolicy,
1761    min_target_height: TargetHeight,
1762    confirmations_policy: ConfirmationsPolicy,
1763    prior_step_results: &[(&Step<N>, StepResult<<DbT as WalletRead>::AccountId>)],
1764    proposal_step: &Step<N>,
1765    #[cfg(feature = "transparent-inputs")] unused_transparent_outputs: &mut HashMap<
1766        StepOutput,
1767        (TransparentAddress, OutPoint),
1768    >,
1769    proposed_version: Option<TxVersion>,
1770    // The transactional bundle padding for the Orchard and Ironwood bundles; the PCZT path
1771    // threads the proposal's configured padding here, other callers pass
1772    // `BundlePadding::DEFAULT`.
1773    orchard_pool_padding: BundlePadding,
1774    // Overrides the builder-derived expiry height, when set. Applied immediately after
1775    // `Builder::new` below, before any inputs are added or signatures/proofs are produced.
1776    expiry_height: Option<BlockHeight>,
1777    // The current time, used to enforce recipient address expiry.
1778    now: SystemTime,
1779) -> Result<
1780    BuildState<ParamsT, <DbT as WalletRead>::AccountId>,
1781    CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>,
1782>
1783where
1784    DbT: WalletWrite + WalletCommitmentTrees,
1785    ParamsT: consensus::Parameters + Clone,
1786    FeeRuleT: FeeRule,
1787{
1788    #[cfg(feature = "transparent-inputs")]
1789    let step_index = prior_step_results.len();
1790
1791    // We only support spending transparent payments or transparent ephemeral outputs from a
1792    // prior step (when "transparent-inputs" is enabled).
1793    #[allow(clippy::never_loop)]
1794    for input_ref in proposal_step.prior_step_inputs() {
1795        let (prior_step, _) = prior_step_results
1796            .get(input_ref.step_index())
1797            .ok_or(ProposalError::ReferenceError(*input_ref))?;
1798
1799        #[allow(unused_variables)]
1800        let output_pool = match input_ref.output_index() {
1801            StepOutputIndex::Payment(i) => prior_step.payment_pools().get(&i).cloned(),
1802            StepOutputIndex::Change(i) => match prior_step.balance().proposed_change().get(i) {
1803                Some(change) if !change.is_ephemeral() => {
1804                    return Err(ProposalError::SpendsChange(*input_ref).into());
1805                }
1806                other => other.map(|change| change.output_pool()),
1807            },
1808        }
1809        .ok_or(ProposalError::ReferenceError(*input_ref))?;
1810
1811        // Return an error on trying to spend a prior output that is not supported.
1812        #[cfg(feature = "transparent-inputs")]
1813        if output_pool != PoolType::TRANSPARENT {
1814            return Err(Error::ProposalNotSupported);
1815        }
1816        #[cfg(not(feature = "transparent-inputs"))]
1817        return Err(Error::ProposalNotSupported);
1818    }
1819
1820    // Every shielded-tree lookup for this step is bound to a single anchor height, so that a
1821    // transaction with only routed shielded outputs (for example, an Orchard-receiver payment
1822    // routed into a fresh Ironwood bundle post-NU6.3) is indistinguishable from one that spends
1823    // real notes in that pool. Any step that produces a shielded bundle carries the anchor selected
1824    // at proposal construction time; a purely transparent step performs no shielded-tree lookup, so
1825    // the value resolved here from the confirmations policy is unused.
1826    let anchor_height = proposal_step
1827        .anchor_height()
1828        .unwrap_or_else(|| confirmations_policy.anchor_height(min_target_height));
1829
1830    let (sapling_anchor, sapling_inputs) = if proposal_step
1831        .involves(PoolType::Shielded(ShieldedPool::Sapling))
1832    {
1833        wallet_db.with_sapling_tree_mut::<_, _, Error<_, _, _, _, _, _>>(|sapling_tree| {
1834            let anchor = sapling_tree
1835                .root_at_checkpoint_id(&anchor_height)?
1836                .ok_or(ProposalError::AnchorNotFound(anchor_height))?
1837                .into();
1838
1839            let sapling_inputs = proposal_step
1840                .shielded_inputs()
1841                .map(|inputs| {
1842                    inputs
1843                        .notes()
1844                        .iter()
1845                        .filter_map(|selected| match selected.note() {
1846                            Note::Sapling(note) => sapling_tree
1847                                .witness_at_checkpoint_id_caching(
1848                                    selected.note_commitment_tree_position(),
1849                                    &anchor_height,
1850                                )
1851                                .and_then(|witness| {
1852                                    witness
1853                                        .ok_or(ShardTreeError::Query(QueryError::CheckpointPruned))
1854                                })
1855                                .map(|merkle_path| {
1856                                    Some((selected.spending_key_scope(), note, merkle_path))
1857                                })
1858                                .map_err(Error::from)
1859                                .transpose(),
1860                            #[cfg(feature = "orchard")]
1861                            Note::Orchard { .. } => None,
1862                        })
1863                        .collect::<Result<Vec<_>, Error<_, _, _, _, _, _>>>()
1864                })
1865                .transpose()?
1866                .unwrap_or_default();
1867
1868            Ok((Some(anchor), sapling_inputs))
1869        })?
1870    } else {
1871        (None, vec![])
1872    };
1873
1874    #[cfg(feature = "orchard")]
1875    let (orchard_anchor, orchard_inputs) = if proposal_step
1876        .involves(PoolType::Shielded(ShieldedPool::Orchard))
1877    {
1878        wallet_db.with_orchard_tree_mut::<_, _, Error<_, _, _, _, _, _>>(|orchard_tree| {
1879            let anchor = orchard_tree
1880                .root_at_checkpoint_id(&anchor_height)?
1881                .ok_or(ProposalError::AnchorNotFound(anchor_height))?
1882                .into();
1883
1884            let orchard_inputs = proposal_step
1885                .shielded_inputs()
1886                .map(|inputs| {
1887                    inputs
1888                        .notes()
1889                        .iter()
1890                        .filter_map(|selected| match selected.note() {
1891                            Note::Orchard {
1892                                note,
1893                                pool: orchard::ValuePool::Orchard,
1894                            } => orchard_tree
1895                                .witness_at_checkpoint_id_caching(
1896                                    selected.note_commitment_tree_position(),
1897                                    &anchor_height,
1898                                )
1899                                .and_then(|witness| {
1900                                    witness
1901                                        .ok_or(ShardTreeError::Query(QueryError::CheckpointPruned))
1902                                })
1903                                .map(|merkle_path| Some((note, merkle_path.into())))
1904                                .map_err(Error::from)
1905                                .transpose(),
1906                            _ => None,
1907                        })
1908                        .collect::<Result<Vec<_>, Error<_, _, _, _, _, _>>>()
1909                })
1910                .transpose()?
1911                .unwrap_or_default();
1912
1913            Ok((Some(anchor), orchard_inputs))
1914        })?
1915    } else {
1916        (None, vec![])
1917    };
1918    #[cfg(not(feature = "orchard"))]
1919    let orchard_anchor = None;
1920
1921    // The Ironwood bundle is required exactly when the step involves the Ironwood pool, mirroring
1922    // the Sapling and Orchard gating above. A payment to an Orchard-protocol receiver post-NU6.3
1923    // targets the Ironwood pool, so input selection classifies it as an Ironwood-pool output; it
1924    // is therefore captured by `involves(Ironwood)`. Transactions with no Ironwood-pool inputs or
1925    // outputs (for example a pure-Sapling spend or a transparent-to-Sapling shielding) must not
1926    // require an Ironwood anchor, even once Ironwood is active.
1927    #[cfg(feature = "orchard")]
1928    let (ironwood_anchor, ironwood_inputs) =
1929        if proposal_step.involves(PoolType::Shielded(ShieldedPool::Ironwood)) {
1930            wallet_db
1931                .with_ironwood_tree_mut::<_, _, Error<_, _, _, _, _, _>>(|ironwood_tree| {
1932                    let anchor = ironwood_tree
1933                        .root_at_checkpoint_id(&anchor_height)?
1934                        .ok_or(ProposalError::AnchorNotFound(anchor_height))?
1935                        .into();
1936
1937                    let ironwood_inputs = proposal_step
1938                        .shielded_inputs()
1939                        .map(|inputs| {
1940                            inputs
1941                                .notes()
1942                                .iter()
1943                                .filter_map(|selected| match selected.note() {
1944                                    Note::Orchard {
1945                                        note,
1946                                        pool: orchard::ValuePool::Ironwood,
1947                                    } => ironwood_tree
1948                                        .witness_at_checkpoint_id_caching(
1949                                            selected.note_commitment_tree_position(),
1950                                            &anchor_height,
1951                                        )
1952                                        .and_then(|witness| {
1953                                            witness.ok_or(ShardTreeError::Query(
1954                                                QueryError::CheckpointPruned,
1955                                            ))
1956                                        })
1957                                        .map(|merkle_path| Some((note, merkle_path.into())))
1958                                        .map_err(Error::from)
1959                                        .transpose(),
1960                                    _ => None,
1961                                })
1962                                .collect::<Result<Vec<_>, Error<_, _, _, _, _, _>>>()
1963                        })
1964                        .transpose()?
1965                        .unwrap_or_default();
1966
1967                    Ok((Some(anchor), ironwood_inputs))
1968                })?
1969                .ok_or(Error::ProposalNotSupported)?
1970        } else {
1971            (None, vec![])
1972        };
1973
1974    #[cfg(not(feature = "orchard"))]
1975    let ironwood_anchor = None;
1976
1977    // Create the transaction. The type of the proposal ensures that there
1978    // The Ironwood bundle's padding is DERIVED from the step, independently of the Orchard
1979    // bundle's: it is unpadded for a canonical ZIP 318 crossing and padded otherwise. The two
1980    // pools' padding is deliberately not coupled — an Orchard bundle is always padded, and the
1981    // Ironwood bundle drops its padding only when doing so makes the transaction indistinguishable
1982    // from a migration transfer. The fee model reaches the same conclusion from the same data (see
1983    // `fees::common::single_pool_output_balance`), which is what keeps the computed fee and the
1984    // built bundle in agreement.
1985    #[cfg(feature = "orchard")]
1986    let ironwood_padding = proposal_step.ironwood_bundle_padding();
1987    #[cfg(not(feature = "orchard"))]
1988    let ironwood_padding = orchard_pool_padding;
1989
1990    // are no possible transparent inputs, so we ignore those here.
1991    let mut builder = Builder::new(
1992        params.clone(),
1993        BlockHeight::from(min_target_height),
1994        BuildConfig::Standard {
1995            sapling_anchor,
1996            orchard_anchor,
1997            ironwood_anchor,
1998            orchard_padding: orchard_pool_padding,
1999            ironwood_padding,
2000        },
2001    );
2002    // A canonical crossing takes the ZIP 318 rolling expiry, which every crossing in the same
2003    // modulus period shares. The builder's ordinary per-transaction expiry (target height plus a
2004    // small delta) would single it out immediately, undoing the shape the unpadded bundle and the
2005    // bucketed anchor were chosen to produce. A caller-supplied expiry is refused rather than
2006    // silently overridden: the padding and anchor are already fixed by this point, so honouring it
2007    // would emit a transaction that is canonical in every respect but one.
2008    #[cfg(feature = "orchard")]
2009    let expiry_height = {
2010        if step_is_canonical_crossing(wallet_db, params, proposal_step, min_target_height) {
2011            match expiry_height {
2012                Some(requested) => {
2013                    return Err(Error::ExpiryHeightConflictsWithCanonicalCrossing { requested });
2014                }
2015                None => Some(zcash_protocol::zip318::expiry_height(
2016                    &zcash_protocol::zip318::BlockSpacing::for_network(params),
2017                    min_target_height.into(),
2018                )),
2019            }
2020        } else {
2021            expiry_height
2022        }
2023    };
2024
2025    if let Some(expiry_height) = expiry_height {
2026        builder = builder.with_expiry_height(expiry_height);
2027    }
2028
2029    // The expiry height the built transaction will carry, or `None` if it does not expire.
2030    let tx_expiry_height = Some(builder.expiry_height()).filter(|h| *h != consensus::H0);
2031
2032    if let Some(version) = proposed_version {
2033        builder.propose_version(version)?;
2034    }
2035
2036    #[cfg(all(feature = "transparent-inputs", not(feature = "orchard")))]
2037    let has_shielded_inputs = !sapling_inputs.is_empty();
2038    #[cfg(all(feature = "transparent-inputs", feature = "orchard"))]
2039    let has_shielded_inputs =
2040        !(sapling_inputs.is_empty() && orchard_inputs.is_empty() && ironwood_inputs.is_empty());
2041
2042    let input_sources = NonEmpty::from_vec({
2043        let mut sources = vec![];
2044        if !sapling_inputs.is_empty() {
2045            sources.push(PoolType::SAPLING);
2046        }
2047        #[cfg(feature = "orchard")]
2048        if !orchard_inputs.is_empty() || !ironwood_inputs.is_empty() {
2049            sources.push(PoolType::ORCHARD);
2050        }
2051        // We assume here that prior step outputs cannot be shielded, due to checks above (and the
2052        // fact that the witness required to spend such outputs could not be computed.)
2053        #[cfg(feature = "transparent-inputs")]
2054        if !(proposal_step.transparent_inputs().is_empty()
2055            && proposal_step.prior_step_inputs().is_empty())
2056        {
2057            sources.push(PoolType::Transparent);
2058        }
2059        sources
2060    })
2061    .ok_or(Error::ProposalNotSupported)?;
2062
2063    for (_sapling_key_scope, sapling_note, merkle_path) in sapling_inputs.into_iter() {
2064        let key = match _sapling_key_scope {
2065            Scope::External => ufvk.sapling().map(|k| k.fvk().clone()),
2066            Scope::Internal => ufvk.sapling().map(|k| k.to_internal_fvk()),
2067        };
2068
2069        builder.add_sapling_spend(
2070            key.ok_or(Error::KeyNotAvailable(PoolType::SAPLING))?,
2071            sapling_note.clone(),
2072            merkle_path,
2073        )?;
2074    }
2075
2076    // Post-NU6.3 the Orchard bundle enforces the cross-address restriction, so Orchard-pool
2077    // change must be returned to a spent Orchard note's own address to remain in the Orchard pool
2078    #[cfg(feature = "orchard")]
2079    for (orchard_note, merkle_path) in orchard_inputs.into_iter() {
2080        builder.add_orchard_spend(
2081            ufvk.orchard()
2082                .cloned()
2083                .ok_or(Error::KeyNotAvailable(PoolType::ORCHARD))?,
2084            *orchard_note,
2085            merkle_path,
2086        )?;
2087    }
2088
2089    #[cfg(feature = "orchard")]
2090    for (ironwood_note, merkle_path) in ironwood_inputs.into_iter() {
2091        builder.add_ironwood_spend(
2092            ufvk.orchard()
2093                .cloned()
2094                .ok_or(Error::KeyNotAvailable(PoolType::ORCHARD))?,
2095            *ironwood_note,
2096            merkle_path,
2097        )?;
2098    }
2099
2100    #[cfg(feature = "transparent-inputs")]
2101    let mut transparent_input_addresses =
2102        HashMap::<TransparentAddress, TransparentAddressMetadata>::new();
2103
2104    #[cfg(feature = "transparent-inputs")]
2105    let mut metadata_from_address = |addr: &TransparentAddress| -> Result<
2106        TransparentAddressMetadata,
2107        CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>,
2108    > {
2109        match transparent_input_addresses.get(addr) {
2110            Some(result) => Ok(result.clone()),
2111            None => {
2112                // `wallet_db.get_transparent_address_metadata` includes reserved ephemeral
2113                // addresses in its lookup. We don't need to include these in order to be
2114                // able to construct ZIP 320 transactions, because in that case the ephemeral
2115                // output is represented via a "change" reference to a previous step. However,
2116                // we do need them in order to create a transaction from a proposal that
2117                // explicitly spends an output from an ephemeral address (only for outputs
2118                // already detected by this wallet instance).
2119
2120                let result = wallet_db
2121                    .get_transparent_address_metadata(account_id, addr)
2122                    .map_err(InputSelectorError::DataSource)?
2123                    .ok_or(Error::AddressNotRecognized(*addr))?;
2124
2125                transparent_input_addresses.insert(*addr, result.clone());
2126                Ok(result)
2127            }
2128        }
2129    };
2130
2131    #[cfg(feature = "transparent-inputs")]
2132    let utxos_spent = {
2133        let mut utxos_spent: Vec<OutPoint> = vec![];
2134        let mut add_transparent_input =
2135            |builder: &mut Builder<_, _>,
2136             utxos_spent: &mut Vec<_>,
2137             recipient_address: &TransparentAddress,
2138             outpoint: OutPoint,
2139             txout: TxOut|
2140             -> Result<(), CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>> {
2141                let metadata = metadata_from_address(recipient_address)?;
2142                match metadata.source() {
2143                    TransparentAddressSource::Derived {
2144                        scope,
2145                        address_index,
2146                    } => {
2147                        let pubkey = ufvk
2148                            .p2pkh()
2149                            .ok_or(Error::KeyNotAvailable(PoolType::Transparent))?
2150                            .derive_address_pubkey(*scope, *address_index)
2151                            .expect("spending key derivation should not fail");
2152                        utxos_spent.push(outpoint.clone());
2153                        builder.add_transparent_p2pkh_input(pubkey, outpoint, txout)?;
2154                    }
2155                    #[cfg(feature = "transparent-key-import")]
2156                    TransparentAddressSource::StandalonePubkey(pubkey) => {
2157                        utxos_spent.push(outpoint.clone());
2158                        builder.add_transparent_p2pkh_input(*pubkey, outpoint, txout)?;
2159                    }
2160                    #[cfg(feature = "transparent-key-import")]
2161                    TransparentAddressSource::StandaloneScript(redeem_script) => {
2162                        let from_chain =
2163                            zs_script::FromChain::parse(&zs_script::Code(redeem_script.to_bytes()))
2164                                .map_err(|_| ::transparent::builder::Error::UnsupportedScript)?;
2165                        utxos_spent.push(outpoint.clone());
2166                        builder.add_transparent_p2sh_input(from_chain, outpoint, txout)?;
2167                    }
2168                    // An address imported without key material cannot construct a spend: the
2169                    // wallet holds neither the pubkey nor the redeem script for the input.
2170                    #[cfg(feature = "transparent-key-import")]
2171                    TransparentAddressSource::StandaloneAddress => {
2172                        return Err(Error::KeyNotAvailable(PoolType::Transparent));
2173                    }
2174                }
2175
2176                Ok(())
2177            };
2178
2179        for utxo in proposal_step.transparent_inputs() {
2180            add_transparent_input(
2181                &mut builder,
2182                &mut utxos_spent,
2183                utxo.recipient_address(),
2184                utxo.outpoint().clone(),
2185                utxo.txout().clone(),
2186            )?;
2187        }
2188        for input_ref in proposal_step.prior_step_inputs() {
2189            // A referenced transparent step output must exist and be referenced *at most* once.
2190            // (Exactly once in the case of ephemeral outputs.)
2191            let (address, outpoint) = unused_transparent_outputs
2192                .remove(input_ref)
2193                .ok_or(Error::Proposal(ProposalError::ReferenceError(*input_ref)))?;
2194
2195            let txout = &prior_step_results[input_ref.step_index()]
2196                .1
2197                .build_result
2198                .transaction()
2199                .transparent_bundle()
2200                .ok_or(ProposalError::ReferenceError(*input_ref))?
2201                .vout[outpoint.n() as usize];
2202
2203            add_transparent_input(
2204                &mut builder,
2205                &mut utxos_spent,
2206                &address,
2207                outpoint,
2208                txout.clone(),
2209            )?;
2210        }
2211        utxos_spent
2212    };
2213
2214    let (external_ovk, internal_ovk) = match ovk_policy {
2215        OvkPolicy::Sender => (
2216            Some(
2217                ufvk.select_ovk(zip32::Scope::External, &input_sources)
2218                    .ok_or(Error::KeyNotAvailable(input_sources.head))?,
2219            ),
2220            None,
2221        ),
2222        OvkPolicy::Custom {
2223            external_ovk,
2224            internal_ovk,
2225        } => (Some(external_ovk), internal_ovk),
2226        OvkPolicy::Discard => (None, None),
2227    };
2228
2229    #[cfg(feature = "orchard")]
2230    let mut orchard_output_meta: Vec<(ShieldedBuildRecipient<_>, Zatoshis, Option<MemoBytes>)> =
2231        vec![];
2232    #[cfg(feature = "orchard")]
2233    let mut ironwood_output_meta: Vec<(
2234        ShieldedBuildRecipient<_>,
2235        Zatoshis,
2236        Option<MemoBytes>,
2237    )> = vec![];
2238    let mut sapling_output_meta: Vec<(ShieldedBuildRecipient<_>, Zatoshis, Option<MemoBytes>)> =
2239        vec![];
2240    let mut transparent_output_meta: Vec<(
2241        TransparentBuildRecipient<_>,
2242        TransparentAddress,
2243        Zatoshis,
2244        StepOutputIndex,
2245    )> = vec![];
2246
2247    for (&payment_index, output_pool) in proposal_step.payment_pools() {
2248        let payment = proposal_step
2249            .transaction_request()
2250            .payments()
2251            .get(&payment_index)
2252            .expect(
2253                "The mapping between payment index and payment is checked in step construction",
2254            );
2255        let payment_amount = payment
2256            .amount()
2257            .ok_or(ProposalError::PaymentAmountMissing(payment_index))?;
2258        let recipient_address = payment.recipient_address();
2259
2260        let add_sapling_output =
2261            |builder: &mut Builder<_, _>,
2262             sapling_output_meta: &mut Vec<_>,
2263             to: sapling::PaymentAddress|
2264             -> Result<(), CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>> {
2265                let memo = payment.memo().map_or_else(MemoBytes::empty, |m| m.clone());
2266                builder.add_sapling_output(
2267                    external_ovk.map(|k| k.into()),
2268                    to,
2269                    payment_amount,
2270                    memo.clone(),
2271                )?;
2272                sapling_output_meta.push((
2273                    ShieldedBuildRecipient::External {
2274                        recipient_address: recipient_address.clone(),
2275                        output_pool: PoolType::SAPLING,
2276                    },
2277                    payment_amount,
2278                    Some(memo),
2279                ));
2280                Ok(())
2281            };
2282
2283        #[cfg(feature = "orchard")]
2284        let add_orchard_output =
2285            |builder: &mut Builder<_, _>,
2286             orchard_output_meta: &mut Vec<_>,
2287             to: orchard::Address|
2288             -> Result<(), CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>> {
2289                let memo = payment.memo().map_or_else(MemoBytes::empty, |m| m.clone());
2290                builder.add_orchard_output(
2291                    external_ovk.map(|k| k.into()),
2292                    to,
2293                    payment_amount,
2294                    memo.clone(),
2295                )?;
2296                orchard_output_meta.push((
2297                    ShieldedBuildRecipient::External {
2298                        recipient_address: recipient_address.clone(),
2299                        output_pool: PoolType::ORCHARD,
2300                    },
2301                    payment_amount,
2302                    Some(memo),
2303                ));
2304                Ok(())
2305            };
2306
2307        #[cfg(feature = "orchard")]
2308        let add_ironwood_output =
2309            |builder: &mut Builder<_, _>,
2310             ironwood_output_meta: &mut Vec<_>,
2311             to: orchard::Address|
2312             -> Result<(), CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>> {
2313                let memo = payment.memo().map_or_else(MemoBytes::empty, |m| m.clone());
2314                builder.add_ironwood_output(
2315                    external_ovk.map(|k| k.into()),
2316                    to,
2317                    payment_amount,
2318                    memo.clone(),
2319                )?;
2320                ironwood_output_meta.push((
2321                    ShieldedBuildRecipient::External {
2322                        recipient_address: recipient_address.clone(),
2323                        // The payment is built into the Ironwood bundle, so its sent-note record
2324                        // belongs to the Ironwood pool. (Post-NU6.3 an Orchard-pool payment output
2325                        // is forbidden when spending Orchard; only change may return to Orchard.)
2326                        output_pool: PoolType::IRONWOOD,
2327                    },
2328                    payment_amount,
2329                    Some(memo),
2330                ));
2331                Ok(())
2332            };
2333
2334        let add_transparent_output =
2335            |builder: &mut Builder<_, _>,
2336             transparent_output_meta: &mut Vec<_>,
2337             to: TransparentAddress|
2338             -> Result<(), CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>> {
2339                if payment.memo().is_some() {
2340                    return Err(Error::Payment(zip321::PaymentError::TransparentMemo));
2341                }
2342                builder.add_transparent_output(&to, payment_amount)?;
2343                transparent_output_meta.push((
2344                    TransparentBuildRecipient::External {
2345                        recipient_address: recipient_address.clone(),
2346                        output_pool: PoolType::TRANSPARENT,
2347                    },
2348                    to,
2349                    payment_amount,
2350                    StepOutputIndex::Payment(payment_index),
2351                ));
2352                Ok(())
2353            };
2354
2355        let recipient = recipient_address
2356            .clone()
2357            .convert_if_network(params.network_type())?;
2358
2359        if let Address::Unified(ua) = &recipient {
2360            check_recipient_expiry(ua, min_target_height, tx_expiry_height, now).map_err(
2361                |error| Error::RecipientAddressExpiry {
2362                    payment_index,
2363                    error,
2364                },
2365            )?;
2366        }
2367
2368        match recipient {
2369            Address::Unified(ua) => match output_pool {
2370                #[cfg(not(feature = "orchard"))]
2371                PoolType::Shielded(ShieldedPool::Orchard) => {
2372                    return Err(Error::ProposalNotSupported);
2373                }
2374                #[cfg(feature = "orchard")]
2375                PoolType::Shielded(ShieldedPool::Orchard) => {
2376                    // Legacy Orchard payment (pre-NU6.3). Once Ironwood is active, input
2377                    // selection assigns Orchard-receiver payments the Ironwood output pool
2378                    // instead, so this arm always builds a plain Orchard output.
2379                    let to = *ua.orchard().expect("The mapping between payment pool and receiver is checked in step construction");
2380                    add_orchard_output(&mut builder, &mut orchard_output_meta, to)?;
2381                }
2382                PoolType::Shielded(ShieldedPool::Sapling) => {
2383                    let to = *ua.sapling().expect("The mapping between payment pool and receiver is checked in step construction");
2384                    add_sapling_output(&mut builder, &mut sapling_output_meta, to)?;
2385                }
2386                PoolType::Transparent => {
2387                    let to = *ua.transparent().expect("The mapping between payment pool and receiver is checked in step construction");
2388                    add_transparent_output(&mut builder, &mut transparent_output_meta, to)?;
2389                }
2390                #[cfg(feature = "orchard")]
2391                PoolType::Shielded(ShieldedPool::Ironwood) => {
2392                    // Ironwood payment (post-NU6.3): delivered to the recipient's Orchard
2393                    // receiver, but through the Ironwood bundle, a pool distinct from Orchard.
2394                    let to = *ua.orchard().expect("The mapping between payment pool and receiver is checked in step construction");
2395                    add_ironwood_output(&mut builder, &mut ironwood_output_meta, to)?;
2396                }
2397                #[cfg(not(feature = "orchard"))]
2398                PoolType::Shielded(ShieldedPool::Ironwood) => {
2399                    return Err(Error::ProposalNotSupported);
2400                }
2401            },
2402            Address::Sapling(to) => {
2403                add_sapling_output(&mut builder, &mut sapling_output_meta, *to)?;
2404            }
2405            Address::Transparent(to) => {
2406                add_transparent_output(&mut builder, &mut transparent_output_meta, to)?;
2407            }
2408            #[cfg(not(feature = "transparent-inputs"))]
2409            Address::Tex(_) => {
2410                return Err(Error::ProposalNotSupported);
2411            }
2412            #[cfg(feature = "transparent-inputs")]
2413            Address::Tex(data) => {
2414                if has_shielded_inputs {
2415                    return Err(ProposalError::PaysTexFromShielded.into());
2416                }
2417                let to = TransparentAddress::PublicKeyHash(data);
2418                add_transparent_output(&mut builder, &mut transparent_output_meta, to)?;
2419            }
2420        }
2421    }
2422
2423    for change_value in proposal_step.balance().proposed_change() {
2424        let memo = change_value
2425            .memo()
2426            .map_or_else(MemoBytes::empty, |m| m.clone());
2427        let output_pool = change_value.output_pool();
2428        match output_pool {
2429            PoolType::Shielded(ShieldedPool::Sapling) => {
2430                builder.add_sapling_output(
2431                    internal_ovk.map(|k| k.into()),
2432                    ufvk.sapling()
2433                        .ok_or(Error::KeyNotAvailable(PoolType::SAPLING))?
2434                        .change_address()
2435                        .1,
2436                    change_value.value(),
2437                    memo.clone(),
2438                )?;
2439                sapling_output_meta.push((
2440                    ShieldedBuildRecipient::InternalShielded {
2441                        receiving_account: account_id,
2442                        external_address: None,
2443                    },
2444                    change_value.value(),
2445                    Some(memo),
2446                ))
2447            }
2448            PoolType::Shielded(ShieldedPool::Orchard) => {
2449                #[cfg(not(feature = "orchard"))]
2450                return Err(Error::UnsupportedChangeType(output_pool));
2451
2452                #[cfg(feature = "orchard")]
2453                {
2454                    let orchard_fvk = ufvk
2455                        .orchard()
2456                        .ok_or(Error::KeyNotAvailable(PoolType::ORCHARD))?;
2457
2458                    let change_address =
2459                        orchard_fvk.address_at(0u32, orchard::keys::Scope::Internal);
2460
2461                    if ironwood_active_at(params, min_target_height) {
2462                        // Post-NU6.3 with an Orchard spend: the change stays in the Orchard pool,
2463                        // returned to a spent note's own address so it satisfies the Orchard V3
2464                        // cross-address restriction. Only the payment crosses into Ironwood. The
2465                        // Orchard V3 bundle forbids ordinary outputs, so the change is added via
2466                        // `add_orchard_change_output`, which pairs it with a fabricated same-address
2467                        builder.add_orchard_change_output(
2468                            orchard_fvk.clone(),
2469                            internal_ovk.map(|k| k.into()),
2470                            change_address,
2471                            change_value.value(),
2472                            memo.clone(),
2473                        )?;
2474                        orchard_output_meta.push((
2475                            ShieldedBuildRecipient::InternalShielded {
2476                                receiving_account: account_id,
2477                                external_address: None,
2478                            },
2479                            change_value.value(),
2480                            Some(memo),
2481                        ))
2482                    } else {
2483                        builder.add_orchard_output(
2484                            internal_ovk.map(|k| k.into()),
2485                            change_address,
2486                            change_value.value(),
2487                            memo.clone(),
2488                        )?;
2489                        orchard_output_meta.push((
2490                            ShieldedBuildRecipient::InternalShielded {
2491                                receiving_account: account_id,
2492                                external_address: None,
2493                            },
2494                            change_value.value(),
2495                            Some(memo),
2496                        ))
2497                    }
2498                }
2499            }
2500            PoolType::Transparent => {
2501                // Transparent change outputs (both ephemeral outputs used in multi-step
2502                // proposals and non-ephemeral change sent to internal-scope addresses)
2503                // are added to the transaction below, after address reservation.
2504                #[cfg(not(feature = "transparent-inputs"))]
2505                return Err(Error::UnsupportedChangeType(output_pool));
2506            }
2507            PoolType::Shielded(ShieldedPool::Ironwood) => {
2508                #[cfg(not(feature = "orchard"))]
2509                return Err(Error::UnsupportedChangeType(output_pool));
2510
2511                #[cfg(feature = "orchard")]
2512                {
2513                    let orchard_fvk = ufvk
2514                        .orchard()
2515                        .ok_or(Error::KeyNotAvailable(PoolType::ORCHARD))?;
2516
2517                    let change_address =
2518                        orchard_fvk.address_at(0u32, orchard::keys::Scope::Internal);
2519
2520                    builder.add_ironwood_output(
2521                        internal_ovk.map(|k| k.into()),
2522                        change_address,
2523                        change_value.value(),
2524                        memo.clone(),
2525                    )?;
2526                    ironwood_output_meta.push((
2527                        ShieldedBuildRecipient::InternalShielded {
2528                            receiving_account: account_id,
2529                            external_address: None,
2530                        },
2531                        change_value.value(),
2532                        Some(memo),
2533                    ))
2534                }
2535            }
2536        }
2537    }
2538
2539    // This reserves the ephemeral addresses even if transaction construction fails.
2540    // It is not worth the complexity of being able to unreserve them, because there
2541    // are few failure modes after this point that would allow us to do so.
2542    #[cfg(feature = "transparent-inputs")]
2543    {
2544        let ephemeral_outputs: Vec<(usize, &ChangeValue)> = proposal_step
2545            .balance()
2546            .proposed_change()
2547            .iter()
2548            .enumerate()
2549            .filter(|(_, change_value)| {
2550                change_value.is_ephemeral() && change_value.output_pool() == PoolType::Transparent
2551            })
2552            .collect();
2553
2554        let addresses_and_metadata = wallet_db
2555            .reserve_next_n_ephemeral_addresses(account_id, ephemeral_outputs.len())
2556            .map_err(Error::DataSource)?;
2557        assert_eq!(addresses_and_metadata.len(), ephemeral_outputs.len());
2558
2559        // We don't need the TransparentAddressSource here; we can look it up from the data source later.
2560        for ((change_index, change_value), (ephemeral_address, _)) in
2561            ephemeral_outputs.iter().zip(addresses_and_metadata)
2562        {
2563            // This output is ephemeral; we will report an error in `create_proposed_transactions`
2564            // if a later step does not consume it.
2565            builder.add_transparent_output(&ephemeral_address, change_value.value())?;
2566            transparent_output_meta.push((
2567                TransparentBuildRecipient::EphemeralTransparent {
2568                    receiving_account: account_id,
2569                    ephemeral_address,
2570                },
2571                ephemeral_address,
2572                change_value.value(),
2573                StepOutputIndex::Change(*change_index),
2574            ))
2575        }
2576    }
2577
2578    // Add any transparent change outputs, sending them to the next available internal-scope
2579    // (change) transparent addresses of the account. As with ephemeral addresses above, this
2580    // reserves the internal addresses even if transaction construction subsequently fails.
2581    #[cfg(feature = "transparent-inputs")]
2582    {
2583        let transparent_change_outputs: Vec<(usize, &ChangeValue)> = proposal_step
2584            .balance()
2585            .proposed_change()
2586            .iter()
2587            .enumerate()
2588            .filter(|(_, change_value)| {
2589                !change_value.is_ephemeral() && change_value.output_pool() == PoolType::Transparent
2590            })
2591            .collect();
2592
2593        if !transparent_change_outputs.is_empty() {
2594            let addresses_and_metadata = wallet_db
2595                .reserve_next_n_internal_addresses(account_id, transparent_change_outputs.len())
2596                .map_err(Error::DataSource)?;
2597            assert_eq!(
2598                addresses_and_metadata.len(),
2599                transparent_change_outputs.len()
2600            );
2601
2602            for ((change_index, change_value), (change_address, _)) in transparent_change_outputs
2603                .iter()
2604                .zip(addresses_and_metadata)
2605            {
2606                builder.add_transparent_output(&change_address, change_value.value())?;
2607                transparent_output_meta.push((
2608                    TransparentBuildRecipient::InternalTransparent {
2609                        receiving_account: account_id,
2610                        recipient_address: change_address,
2611                    },
2612                    change_address,
2613                    change_value.value(),
2614                    StepOutputIndex::Change(*change_index),
2615                ))
2616            }
2617        }
2618    }
2619
2620    Ok(BuildState {
2621        #[cfg(feature = "transparent-inputs")]
2622        step_index,
2623        builder,
2624        #[cfg(feature = "transparent-inputs")]
2625        transparent_input_addresses,
2626        #[cfg(feature = "orchard")]
2627        orchard_output_meta,
2628        #[cfg(feature = "orchard")]
2629        ironwood_output_meta,
2630        sapling_output_meta,
2631        transparent_output_meta,
2632        #[cfg(feature = "transparent-inputs")]
2633        utxos_spent,
2634    })
2635}
2636
2637// `unused_transparent_outputs` maps `StepOutput`s for transparent outputs
2638// that have not been consumed so far, to the corresponding pair of
2639// `TransparentAddress` and `Outpoint`.
2640#[allow(clippy::too_many_arguments)]
2641#[allow(clippy::type_complexity)]
2642fn create_proposed_transaction<DbT, ParamsT, InputsErrT, FeeRuleT, ChangeErrT, N>(
2643    wallet_db: &mut DbT,
2644    params: &ParamsT,
2645    rng: &mut impl CryptoRng,
2646    spend_prover: &impl SpendProver,
2647    output_prover: &impl OutputProver,
2648    spending_keys: &SpendingKeys,
2649    account_id: <DbT as WalletRead>::AccountId,
2650    ovk_policy: OvkPolicy,
2651    fee_rule: &FeeRuleT,
2652    min_target_height: TargetHeight,
2653    confirmations_policy: ConfirmationsPolicy,
2654    prior_step_results: &[(&Step<N>, StepResult<<DbT as WalletRead>::AccountId>)],
2655    proposal_step: &Step<N>,
2656    #[cfg(feature = "transparent-inputs")] unused_transparent_outputs: &mut HashMap<
2657        StepOutput,
2658        (TransparentAddress, OutPoint),
2659    >,
2660    proposed_version: Option<TxVersion>,
2661    expiry_height: Option<BlockHeight>,
2662    now: SystemTime,
2663) -> Result<
2664    StepResult<<DbT as WalletRead>::AccountId>,
2665    CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>,
2666>
2667where
2668    DbT: WalletWrite + WalletCommitmentTrees,
2669    ParamsT: consensus::Parameters + Clone,
2670    FeeRuleT: FeeRule,
2671{
2672    let build_state = build_proposed_transaction::<_, _, _, FeeRuleT, _, _>(
2673        wallet_db,
2674        params,
2675        &spending_keys.usk.to_unified_full_viewing_key(),
2676        account_id,
2677        ovk_policy,
2678        min_target_height,
2679        confirmations_policy,
2680        prior_step_results,
2681        proposal_step,
2682        #[cfg(feature = "transparent-inputs")]
2683        unused_transparent_outputs,
2684        proposed_version,
2685        // The non-PCZT path always builds padded Orchard-pool bundles.
2686        BundlePadding::DEFAULT,
2687        expiry_height,
2688        now,
2689    )?;
2690
2691    // Build the transaction with the specified fee rule
2692    #[cfg_attr(not(feature = "transparent-inputs"), allow(unused_mut))]
2693    let mut transparent_signing_set = TransparentSigningSet::new();
2694    #[cfg(feature = "transparent-inputs")]
2695    for (_address, address_metadata) in build_state.transparent_input_addresses {
2696        match address_metadata.source() {
2697            TransparentAddressSource::Derived {
2698                scope,
2699                address_index,
2700            } => {
2701                transparent_signing_set.add_key(
2702                    spending_keys
2703                        .usk
2704                        .transparent()
2705                        .derive_secret_key(*scope, *address_index)
2706                        .expect("spending key derivation should not fail"),
2707                );
2708            }
2709            #[cfg(feature = "transparent-key-import")]
2710            TransparentAddressSource::StandalonePubkey(_)
2711            | TransparentAddressSource::StandaloneScript(_) => {
2712                let keys = spending_keys
2713                    .standalone_transparent_keys
2714                    .get(&_address)
2715                    .ok_or(Error::AddressNotRecognized(_address))?;
2716                for key in keys {
2717                    transparent_signing_set.add_key(*key);
2718                }
2719            }
2720            // Unreachable in practice: inputs from an address imported without key material
2721            // are rejected when the proposed transaction is constructed.
2722            #[cfg(feature = "transparent-key-import")]
2723            TransparentAddressSource::StandaloneAddress => {
2724                return Err(Error::AddressNotRecognized(_address));
2725            }
2726        }
2727    }
2728    let sapling_extsks = &[
2729        spending_keys.usk.sapling().clone(),
2730        spending_keys
2731            .usk
2732            .sapling()
2733            .derive_internal()
2734            .ok_or(Error::KeyNotAvailable(PoolType::SAPLING))?,
2735    ];
2736    #[cfg(feature = "orchard")]
2737    let orchard_saks = &[spending_keys.usk.orchard().into()];
2738    #[cfg(not(feature = "orchard"))]
2739    let orchard_saks = &[];
2740    let build_result = build_state.builder.build(
2741        &transparent_signing_set,
2742        sapling_extsks,
2743        orchard_saks,
2744        rng,
2745        spend_prover,
2746        output_prover,
2747        fee_rule,
2748    )?;
2749
2750    #[cfg(feature = "orchard")]
2751    let orchard_fvk: orchard::keys::FullViewingKey = spending_keys.usk.orchard().into();
2752    #[cfg(feature = "orchard")]
2753    let orchard_internal_ivk = orchard_fvk.to_ivk(orchard::keys::Scope::Internal);
2754    // Same-address Orchard change (post-NU6.3, when spending Orchard notes) is returned to a spent
2755    // note's own address, which may be external-scoped; try the external IVK as well so such change
2756    // outputs remain decryptable for recording.
2757    #[cfg(feature = "orchard")]
2758    let orchard_external_ivk = orchard_fvk.to_ivk(orchard::keys::Scope::External);
2759    #[cfg(feature = "orchard")]
2760    let orchard_outputs = build_state.orchard_output_meta.into_iter().enumerate().map(
2761        |(i, (recipient, value, memo))| {
2762            let output_index = build_result
2763                .orchard_meta()
2764                .output_action_index(i)
2765                .expect("An action should exist in the transaction for each Orchard output.");
2766
2767            let recipient = recipient.into_recipient(|| {
2768                build_result
2769                    .transaction()
2770                    .orchard_bundle()
2771                    .and_then(|bundle| {
2772                        bundle
2773                            .decrypt_output_with_key(output_index, &orchard_internal_ivk)
2774                            .or_else(|| {
2775                                bundle.decrypt_output_with_key(output_index, &orchard_external_ivk)
2776                            })
2777                            .map(|(note, _, _)| Note::Orchard {
2778                                note,
2779                                pool: orchard::ValuePool::Orchard,
2780                            })
2781                    })
2782                    .expect("Wallet-internal outputs must be decryptable with the wallet's IVK")
2783            });
2784
2785            SentTransactionOutput::from_parts_in_tree(
2786                Some(NoteCommitmentTree::Orchard),
2787                output_index,
2788                recipient,
2789                value,
2790                memo,
2791            )
2792        },
2793    );
2794
2795    #[cfg(feature = "orchard")]
2796    let ironwood_outputs = build_state
2797        .ironwood_output_meta
2798        .into_iter()
2799        .enumerate()
2800        .map(|(i, (recipient, value, memo))| {
2801            let raw_output_index = build_result
2802                .ironwood_meta()
2803                .output_action_index(i)
2804                .expect("An action should exist in the transaction for each Ironwood output.");
2805
2806            let recipient = recipient.into_recipient(|| {
2807                build_result
2808                    .transaction()
2809                    .ironwood_bundle()
2810                    .and_then(|bundle| {
2811                        bundle
2812                            .decrypt_output_with_key(raw_output_index, &orchard_internal_ivk)
2813                            .map(|(note, _, _)| Note::Orchard { note, pool: orchard::ValuePool::Ironwood })
2814                    })
2815                    .expect(
2816                        "Wallet-internal Ironwood outputs must be decryptable with the wallet's IVK",
2817                    )
2818            });
2819
2820            // The Ironwood output is stored at its raw index within the Ironwood bundle — the same
2821            // index the scanner assigns — so the send and scan write paths agree. Each shielded
2822            // pool has its own received-notes table keyed by (transaction, action_index), so
2823            // Orchard and Ironwood indices never collide despite both starting at zero.
2824            SentTransactionOutput::from_parts_in_tree(
2825                Some(NoteCommitmentTree::Ironwood),
2826                raw_output_index,
2827                recipient,
2828                value,
2829                memo,
2830            )
2831        });
2832
2833    let sapling_dfvk = spending_keys
2834        .usk
2835        .sapling()
2836        .to_diversifiable_full_viewing_key();
2837    let sapling_internal_ivk =
2838        PreparedIncomingViewingKey::new(&sapling_dfvk.to_ivk(Scope::Internal));
2839    let sapling_outputs = build_state.sapling_output_meta.into_iter().enumerate().map(
2840        |(i, (recipient, value, memo))| {
2841            let output_index = build_result
2842                .sapling_meta()
2843                .output_index(i)
2844                .expect("An output should exist in the transaction for each Sapling payment.");
2845
2846            let recipient = recipient.into_recipient(|| {
2847                build_result
2848                    .transaction()
2849                    .sapling_bundle()
2850                    .and_then(|bundle| {
2851                        try_sapling_note_decryption(
2852                            &sapling_internal_ivk,
2853                            &bundle.shielded_outputs()[output_index],
2854                            zip212_enforcement(params, min_target_height.into()),
2855                        )
2856                        .map(|(note, _, _)| Note::Sapling(note))
2857                    })
2858                    .expect("Wallet-internal outputs must be decryptable with the wallet's IVK")
2859            });
2860
2861            SentTransactionOutput::from_parts_in_tree(
2862                Some(NoteCommitmentTree::Sapling),
2863                output_index,
2864                recipient,
2865                value,
2866                memo,
2867            )
2868        },
2869    );
2870
2871    let txid: [u8; 32] = build_result.transaction().txid().into();
2872    assert_eq!(
2873        build_state.transparent_output_meta.len(),
2874        build_result
2875            .transaction()
2876            .transparent_bundle()
2877            .map_or(0, |b| b.vout.len()),
2878    );
2879
2880    #[allow(unused_variables)]
2881    let transparent_outputs = build_state
2882        .transparent_output_meta
2883        .into_iter()
2884        .enumerate()
2885        .map(|(n, (recipient, address, value, step_output_index))| {
2886            // This assumes that transparent outputs are pushed onto `transparent_output_meta`
2887            // with the same indices they have in the transaction's transparent outputs.
2888            // We do not reorder transparent outputs; there is no reason to do so because it
2889            // would not usefully improve privacy.
2890            let outpoint = OutPoint::new(txid, n as u32);
2891
2892            let recipient = recipient.into_recipient(
2893                #[cfg(feature = "transparent-inputs")]
2894                outpoint.clone(),
2895            );
2896
2897            #[cfg(feature = "transparent-inputs")]
2898            unused_transparent_outputs.insert(
2899                StepOutput::new(build_state.step_index, step_output_index),
2900                (address, outpoint),
2901            );
2902            SentTransactionOutput::from_parts(n, recipient, value, None)
2903        });
2904
2905    let mut outputs: Vec<SentTransactionOutput<_>> = vec![];
2906    #[cfg(feature = "orchard")]
2907    outputs.extend(orchard_outputs);
2908    #[cfg(feature = "orchard")]
2909    outputs.extend(ironwood_outputs);
2910    outputs.extend(sapling_outputs);
2911    outputs.extend(transparent_outputs);
2912
2913    Ok(StepResult {
2914        build_result,
2915        outputs,
2916        fee_amount: proposal_step.balance().fee_required(),
2917        #[cfg(feature = "transparent-inputs")]
2918        utxos_spent: build_state.utxos_spent,
2919    })
2920}
2921
2922/// Constructs a transaction using the inputs supplied by the given proposal.
2923///
2924/// Only single-step proposals are currently supported.
2925///
2926/// Returns a partially-created Zcash transaction (PCZT) that is ready to be authorized.
2927/// You can use the following roles for this:
2928/// - [`pczt::roles::prover::Prover`]
2929/// - [`pczt::roles::signer::Signer`] (if you have local access to the spend authorizing
2930///   keys)
2931/// - [`pczt::roles::combiner::Combiner`] (if you create proofs and apply signatures in
2932///   parallel)
2933///
2934/// Before sending the PCZT to an external Signer, create a signer view with
2935/// [`redact_pczt_for_signer`] and retain the original for combination.
2936/// A Signer that independently obtains the relevant full viewing keys and returns
2937/// only Orchard-protocol signature contributions can instead use
2938/// [`redact_pczt_for_batch_signer`].
2939///
2940/// Once the PCZT fully authorized, call [`extract_and_store_transaction_from_pczt`] to
2941/// finish transaction creation.
2942///
2943/// `expiry_height`, when set, replaces the builder-derived expiry before
2944/// the PCZT is finalized. Applying this override after finalization can invalidate
2945/// dummy spend signatures.
2946///
2947/// A nonzero `expiry_height` below the proposal's
2948/// [`min_target_height`](Proposal::min_target_height) is rejected with
2949/// [`Error::ExpiryHeightBelowTargetHeight`]. An `expiry_height` of zero,
2950/// which disables expiry, is exempt from this check.
2951///
2952/// `orchard_pool_padding` selects the transactional bundle padding for the Orchard
2953/// bundle; pass
2954/// [`BundlePadding::DEFAULT`](zcash_primitives::transaction::builder::BundlePadding).
2955/// The Ironwood bundle's padding is not a caller's to choose: it is derived from the
2956/// proposal by [`Step::ironwood_bundle_padding`](crate::proposal::Step::ironwood_bundle_padding),
2957/// so that it matches the action count the fee was computed from.
2958///
2959/// `rng` supplies the randomness used to construct the PCZT and to sign its dummy spends.
2960#[allow(clippy::too_many_arguments)]
2961#[allow(clippy::type_complexity)]
2962#[cfg(feature = "pczt")]
2963pub fn create_pczt_from_proposal<DbT, ParamsT, InputsErrT, FeeRuleT, ChangeErrT, N>(
2964    wallet_db: &mut DbT,
2965    params: &ParamsT,
2966    clock: &impl Clock,
2967    rng: &mut impl CryptoRng,
2968    account_id: <DbT as WalletRead>::AccountId,
2969    ovk_policy: OvkPolicy,
2970    proposal: &Proposal<FeeRuleT, N>,
2971    expiry_height: Option<BlockHeight>,
2972    orchard_pool_padding: BundlePadding,
2973) -> Result<pczt::Pczt, CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>>
2974where
2975    DbT: WalletWrite + WalletCommitmentTrees,
2976    ParamsT: consensus::Parameters + Clone,
2977    FeeRuleT: FeeRule,
2978    <DbT as WalletRead>::AccountId: serde::Serialize,
2979{
2980    let account = wallet_db
2981        .get_account(account_id)
2982        .map_err(Error::DataSource)?
2983        .ok_or(Error::AccountIdNotRecognized)?;
2984    let ufvk = account.ufvk().ok_or(Error::AccountCannotSpend)?;
2985    let account_derivation = account.source().key_derivation();
2986
2987    // For now we only support turning single-step proposals into PCZTs.
2988    if proposal.steps().len() > 1 {
2989        return Err(Error::ProposalNotSupported);
2990    }
2991
2992    // Defense-in-depth: reject oversized proposals at the build entry point.
2993    proposal.check_transaction_size()?;
2994
2995    let fee_rule = proposal.fee_rule();
2996    let min_target_height = proposal.min_target_height();
2997
2998    if let Some(expiry_height) = expiry_height {
2999        let min_target_height = BlockHeight::from(min_target_height);
3000        if expiry_height != consensus::H0 && expiry_height < min_target_height {
3001            return Err(Error::ExpiryHeightBelowTargetHeight {
3002                expiry_height,
3003                min_target_height,
3004            });
3005        }
3006    }
3007
3008    let prior_step_results = &[];
3009    let proposal_step = proposal.steps().first();
3010
3011    let unused_transparent_outputs = &mut HashMap::new();
3012    // Build at the version the proposal requested, falling back to the version implied by the
3013    // target height (version 6 from NU6.3 onward). Both the version 6 format and its Ironwood
3014    // bundle are fully representable as a PCZT.
3015    let proposed_version = proposal.proposed_version();
3016
3017    let build_state = build_proposed_transaction::<_, _, _, FeeRuleT, _, _>(
3018        wallet_db,
3019        params,
3020        ufvk,
3021        account_id,
3022        ovk_policy,
3023        min_target_height,
3024        proposal.confirmations_policy(),
3025        prior_step_results,
3026        proposal_step,
3027        #[cfg(feature = "transparent-inputs")]
3028        unused_transparent_outputs,
3029        proposed_version,
3030        orchard_pool_padding,
3031        expiry_height,
3032        clock.now(),
3033    )?;
3034
3035    // Build the transaction with the specified fee rule. The caller's expiry override
3036    // (validated against canonical ZIP 318 crossings) was applied via the builder in
3037    // `build_proposed_transaction` above, so the PCZT parts already carry it.
3038    let build_result = build_state.builder.build_for_pczt(&mut *rng, fee_rule)?;
3039
3040    let created = Creator::build_from_parts(build_result.pczt_parts).ok_or(PcztError::Build)?;
3041
3042    let io_finalized = IoFinalizer::new(created).finalize_io(rng)?;
3043
3044    #[cfg(feature = "orchard")]
3045    let orchard_outputs = build_state
3046        .orchard_output_meta
3047        .into_iter()
3048        .enumerate()
3049        .map(|(i, (recipient, _, _))| {
3050            let output_index = build_result
3051                .orchard_meta
3052                .output_action_index(i)
3053                .expect("An action should exist in the transaction for each Orchard output.");
3054
3055            (
3056                output_index,
3057                PcztRecipient::from_shielded_recipient(recipient),
3058            )
3059        })
3060        .collect::<HashMap<_, _>>();
3061
3062    #[cfg(feature = "orchard")]
3063    let ironwood_outputs = build_state
3064        .ironwood_output_meta
3065        .into_iter()
3066        .enumerate()
3067        .map(|(i, (recipient, _, _))| {
3068            let output_index = build_result
3069                .ironwood_meta
3070                .output_action_index(i)
3071                .expect("An action should exist in the transaction for each Ironwood output.");
3072
3073            (
3074                output_index,
3075                PcztRecipient::from_shielded_recipient(recipient),
3076            )
3077        })
3078        .collect::<HashMap<_, _>>();
3079
3080    let sapling_outputs = build_state
3081        .sapling_output_meta
3082        .into_iter()
3083        .enumerate()
3084        .map(|(i, (recipient, _, _))| {
3085            let output_index = build_result
3086                .sapling_meta
3087                .output_index(i)
3088                .expect("An output should exist in the transaction for each Sapling output.");
3089
3090            (
3091                output_index,
3092                PcztRecipient::from_shielded_recipient(recipient),
3093            )
3094        })
3095        .collect::<HashMap<_, _>>();
3096
3097    let pczt = Updater::new(io_finalized)
3098        .update_global_with(|mut updater| {
3099            updater.set_proprietary(
3100                PROPRIETARY_PROPOSAL_INFO.into(),
3101                postcard::to_allocvec(&ProposalInfo::<<DbT as WalletRead>::AccountId> {
3102                    from_account: account_id,
3103                    target_height: proposal.min_target_height(),
3104                })
3105                .expect("postcard encoding of PCZT proposal metadata should not fail"),
3106            )
3107        })
3108        .update_orchard_with(|mut updater| {
3109            // An action still needs a spend authorization signature if and only if its spend
3110            // requires one: this covers both the requested real spends and the wallet-controlled
3111            // zero-value spends the builder pairs with change outputs in the vanilla-Orchard-pool
3112            // bundle under the post-NU6.3 cross-address rule. Protocol padding dummies are
3113            // pre-signed and cleared by the IO Finalizer, so they are excluded by this check.
3114            let spend_needs_derivation = updater
3115                .bundle()
3116                .actions()
3117                .iter()
3118                .map(|action| action.spend().spend_auth_sig().is_none())
3119                .collect::<Vec<_>>();
3120
3121            for (index, needs_derivation) in spend_needs_derivation.iter().enumerate() {
3122                updater.update_action_with(index, |mut action_updater| {
3123                    // If the account has a known derivation, add the Orchard key path to the PCZT
3124                    // for every spend that still requires a signature (real spends and
3125                    // wallet-controlled zero-value spends), so an external Signer can identify
3126                    // and sign it.
3127                    if let Some(derivation) = account_derivation
3128                        && *needs_derivation
3129                    {
3130                        // All spent notes are from the same account.
3131                        action_updater.set_spend_zip32_derivation(
3132                            orchard::pczt::Zip32Derivation::parse(
3133                                derivation.seed_fingerprint().to_bytes(),
3134                                vec![
3135                                    zip32::ChildIndex::hardened(32).index(),
3136                                    zip32::ChildIndex::hardened(params.network_type().coin_type())
3137                                        .index(),
3138                                    zip32::ChildIndex::hardened(u32::from(
3139                                        derivation.account_index(),
3140                                    ))
3141                                    .index(),
3142                                ],
3143                            )
3144                            .expect("valid"),
3145                        );
3146                    }
3147
3148                    if let Some((pczt_recipient, external_address)) = orchard_outputs.get(&index) {
3149                        if let Some(user_address) = external_address {
3150                            action_updater.set_output_user_address(user_address.encode());
3151                        }
3152                        action_updater.set_output_proprietary(
3153                            PROPRIETARY_OUTPUT_INFO.into(),
3154                            postcard::to_allocvec(pczt_recipient).expect(
3155                                "postcard encoding of PCZT recipient metadata should not fail",
3156                            ),
3157                        );
3158                    }
3159
3160                    Ok(())
3161                })?;
3162            }
3163            Ok(())
3164        })?
3165        .update_ironwood_with(|mut updater| {
3166            // An action still needs a spend authorization signature if and only if its spend
3167            // requires one: this covers both the requested real spends and the wallet-controlled
3168            // zero-value spends the builder pairs with change outputs in the vanilla-Orchard-pool
3169            // bundle under the post-NU6.3 cross-address rule. Protocol padding dummies are
3170            // pre-signed and cleared by the IO Finalizer, so they are excluded by this check.
3171            let spend_needs_derivation = updater
3172                .bundle()
3173                .actions()
3174                .iter()
3175                .map(|action| action.spend().spend_auth_sig().is_none())
3176                .collect::<Vec<_>>();
3177
3178            for (index, needs_derivation) in spend_needs_derivation.iter().enumerate() {
3179                updater.update_action_with(index, |mut action_updater| {
3180                    // Ironwood notes are spent with the account's Orchard spending key, so the key
3181                    // path added here is the Orchard one. If the account has a known derivation,
3182                    // add it for every spend that still requires a signature (real spends and
3183                    // wallet-controlled zero-value spends), so an external Signer can identify
3184                    // and sign it.
3185                    if let Some(derivation) = account_derivation
3186                        && *needs_derivation
3187                    {
3188                        // All spent notes are from the same account.
3189                        action_updater.set_spend_zip32_derivation(
3190                            orchard::pczt::Zip32Derivation::parse(
3191                                derivation.seed_fingerprint().to_bytes(),
3192                                vec![
3193                                    zip32::ChildIndex::hardened(32).index(),
3194                                    zip32::ChildIndex::hardened(params.network_type().coin_type())
3195                                        .index(),
3196                                    zip32::ChildIndex::hardened(u32::from(
3197                                        derivation.account_index(),
3198                                    ))
3199                                    .index(),
3200                                ],
3201                            )
3202                            .expect("valid"),
3203                        );
3204                    }
3205
3206                    if let Some((pczt_recipient, external_address)) = ironwood_outputs.get(&index) {
3207                        if let Some(user_address) = external_address {
3208                            action_updater.set_output_user_address(user_address.encode());
3209                        }
3210                        action_updater.set_output_proprietary(
3211                            PROPRIETARY_OUTPUT_INFO.into(),
3212                            postcard::to_allocvec(pczt_recipient).expect(
3213                                "postcard encoding of PCZT recipient metadata should not fail",
3214                            ),
3215                        );
3216                    }
3217
3218                    Ok(())
3219                })?;
3220            }
3221            Ok(())
3222        })?
3223        .update_sapling_with(|mut updater| {
3224            // If the account has a known derivation, add the Sapling key path to the PCZT.
3225            if let Some(derivation) = account_derivation {
3226                let non_dummy_spends = updater
3227                    .bundle()
3228                    .spends()
3229                    .iter()
3230                    .enumerate()
3231                    .filter_map(|(index, spend)| {
3232                        // Dummy spends will already have a proof generation key.
3233                        spend.proof_generation_key().is_none().then_some(index)
3234                    })
3235                    .collect::<Vec<_>>();
3236
3237                for index in non_dummy_spends {
3238                    updater.update_spend_with(index, |mut spend_updater| {
3239                        // All non-dummy spent notes are from the same account.
3240                        spend_updater.set_zip32_derivation(
3241                            sapling::pczt::Zip32Derivation::parse(
3242                                derivation.seed_fingerprint().to_bytes(),
3243                                vec![
3244                                    zip32::ChildIndex::hardened(32).index(),
3245                                    zip32::ChildIndex::hardened(params.network_type().coin_type())
3246                                        .index(),
3247                                    zip32::ChildIndex::hardened(u32::from(
3248                                        derivation.account_index(),
3249                                    ))
3250                                    .index(),
3251                                ],
3252                            )
3253                            .expect("valid"),
3254                        );
3255                        Ok(())
3256                    })?;
3257                }
3258            }
3259
3260            for index in 0..updater.bundle().outputs().len() {
3261                if let Some((pczt_recipient, external_address)) = sapling_outputs.get(&index) {
3262                    updater.update_output_with(index, |mut output_updater| {
3263                        if let Some(user_address) = external_address {
3264                            output_updater.set_user_address(user_address.encode());
3265                        }
3266                        output_updater.set_proprietary(
3267                            PROPRIETARY_OUTPUT_INFO.into(),
3268                            postcard::to_allocvec(pczt_recipient).expect(
3269                                "postcard encoding of PCZT recipient metadata should not fail",
3270                            ),
3271                        );
3272                        Ok(())
3273                    })?;
3274                }
3275            }
3276
3277            Ok(())
3278        })?
3279        .update_transparent_with(|mut updater| {
3280            // If the account has a known derivation, add the transparent key paths to the PCZT.
3281            if let Some(derivation) = account_derivation {
3282                // Match address metadata to the inputs that spend from those addresses.
3283                let inputs_to_update = updater
3284                    .bundle()
3285                    .inputs()
3286                    .iter()
3287                    .enumerate()
3288                    .filter_map(|(index, input)| {
3289                        let address_metadata = build_state.transparent_input_addresses.get(
3290                            &TransparentAddress::from_script_from_chain(input.script_pubkey())
3291                                .expect("we created this with a supported transparent address"),
3292                        )?;
3293                        match address_metadata.source() {
3294                            TransparentAddressSource::Derived {
3295                                scope,
3296                                address_index,
3297                            } => Some((index, *scope, *address_index)),
3298                            #[cfg(feature = "transparent-key-import")]
3299                            TransparentAddressSource::StandalonePubkey(_)
3300                            | TransparentAddressSource::StandaloneScript(_)
3301                            | TransparentAddressSource::StandaloneAddress => None,
3302                        }
3303                    })
3304                    .collect::<Vec<_>>();
3305
3306                for (index, scope, address_index) in inputs_to_update {
3307                    updater.update_input_with(index, |mut input_updater| {
3308                        let pubkey = ufvk
3309                            .p2pkh()
3310                            .expect("we derived this successfully in build_proposed_transaction")
3311                            .derive_address_pubkey(scope, address_index)
3312                            .expect("spending key derivation should not fail");
3313
3314                        input_updater.set_bip32_derivation(
3315                            pubkey.serialize(),
3316                            Bip32Derivation::parse(
3317                                derivation.seed_fingerprint().to_bytes(),
3318                                vec![
3319                                    // Transparent uses BIP 44 derivation.
3320                                    44 | ChildNumber::HARDENED_FLAG,
3321                                    params.network_type().coin_type() | ChildNumber::HARDENED_FLAG,
3322                                    u32::from(derivation.account_index())
3323                                        | ChildNumber::HARDENED_FLAG,
3324                                    ChildNumber::from(scope).into(),
3325                                    ChildNumber::from(address_index).into(),
3326                                ],
3327                            )
3328                            .expect("valid"),
3329                        );
3330                        Ok(())
3331                    })?;
3332                }
3333            }
3334
3335            assert_eq!(
3336                build_state.transparent_output_meta.len(),
3337                updater.bundle().outputs().len(),
3338            );
3339            for (index, (recipient, _, _, _)) in
3340                build_state.transparent_output_meta.into_iter().enumerate()
3341            {
3342                updater.update_output_with(index, |mut output_updater| {
3343                    let (pczt_recipient, external_address) =
3344                        PcztRecipient::from_transparent_recipient(recipient);
3345                    if let Some(user_address) = external_address {
3346                        output_updater.set_user_address(user_address.encode());
3347                    }
3348                    output_updater.set_proprietary(
3349                        PROPRIETARY_OUTPUT_INFO.into(),
3350                        postcard::to_allocvec(&pczt_recipient)
3351                            .expect("postcard encoding of pczt recipient metadata should not fail"),
3352                    );
3353                    Ok(())
3354                })?;
3355            }
3356
3357            Ok(())
3358        })?
3359        .finish();
3360
3361    Ok(pczt)
3362}
3363
3364/// Selects the view produced by [`redact_pczt_for_signer`], according to the
3365/// capabilities of the receiving Signer.
3366#[cfg(feature = "pczt")]
3367#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3368pub enum SignerView {
3369    /// The conservative signer view, for general-purpose Signers including
3370    /// deployed hardware signers (e.g. the Keystone ordinary send flow), which
3371    /// this view is validated against in the field.
3372    ///
3373    /// It removes wallet metadata, Orchard-protocol and Sapling spend
3374    /// witnesses, and dummy signing keys (a defensive no-op for IO-finalized
3375    /// PCZTs, whose dummy keys were consumed by the IO Finalizer; per the PCZT
3376    /// specification, Signers MUST reject PCZTs that contain them). It retains
3377    /// proofs, binding-signature keys, and anchors, and performs no field
3378    /// compaction, so the view of a v5 transaction remains representable in
3379    /// the v1 PCZT encoding (which [`pczt::Pczt::serialize`] selects for it).
3380    Full,
3381    /// The compact signer view, for receivers that support the v2 PCZT
3382    /// encoding and can restore compacted fields with
3383    /// [`pczt::Pczt::resolve_fields`].
3384    ///
3385    /// In addition to the `Full` redactions — except that Sapling spend
3386    /// witnesses are retained so that the Signer may verify nullifiers —
3387    /// this clears zk-proofs and binding-signature keys, compacts resolvable
3388    /// Orchard-protocol fields, and removes v6 shielded anchors (v5 anchors
3389    /// are retained because signatures commit to them).
3390    Compact,
3391}
3392
3393/// Creates a redacted copy of a wallet PCZT for an external Signer.
3394///
3395/// This is intended for PCZTs returned by [`create_pczt_from_proposal`]. The
3396/// `view` argument selects the redaction policy; see [`SignerView`] for the
3397/// receiver capabilities each view requires. When in doubt — in particular for
3398/// hardware signers whose firmware predates the compact view — use
3399/// [`SignerView::Full`].
3400///
3401/// The returned PCZT retains information that a general-purpose Signer may
3402/// need, including full viewing keys, spend authorization randomizers, key
3403/// derivation paths, output recovery keys, and user-facing addresses.
3404/// Applications may apply additional redaction when they know their Signer's
3405/// capabilities. A Signer that independently obtains the relevant full viewing
3406/// keys and returns only Orchard-protocol signature contributions can instead
3407/// use [`redact_pczt_for_batch_signer`].
3408///
3409/// The caller must retain `pczt` and combine the Signer's contribution into
3410/// that authoritative copy. The returned signer view omits wallet metadata and
3411/// other fields that [`extract_and_store_transaction_from_pczt`] requires.
3412#[cfg(feature = "pczt")]
3413pub fn redact_pczt_for_signer(pczt: &pczt::Pczt, view: SignerView) -> pczt::Pczt {
3414    match view {
3415        SignerView::Full => full_signer_view(pczt),
3416        SignerView::Compact => compact_signer_view(pczt),
3417    }
3418}
3419
3420#[cfg(feature = "pczt")]
3421fn full_signer_view(pczt: &pczt::Pczt) -> pczt::Pczt {
3422    fn redact_orchard_bundle(mut redactor: pczt::roles::redactor::orchard::OrchardRedactor<'_>) {
3423        redactor.redact_actions(|mut action| {
3424            action.clear_spend_witness();
3425            action.clear_spend_dummy_sk();
3426            action.redact_output_proprietary(PROPRIETARY_OUTPUT_INFO);
3427        });
3428    }
3429
3430    Redactor::new(pczt.clone())
3431        .redact_global_with(|mut redactor| {
3432            redactor.redact_proprietary(PROPRIETARY_PROPOSAL_INFO);
3433        })
3434        .redact_transparent_with(|mut redactor| {
3435            redactor.redact_outputs(|mut output| {
3436                output.redact_proprietary(PROPRIETARY_OUTPUT_INFO);
3437            });
3438        })
3439        .redact_sapling_with(|mut redactor| {
3440            redactor.redact_spends(|mut spend| {
3441                spend.clear_witness();
3442                spend.clear_dummy_ask();
3443            });
3444            redactor.redact_outputs(|mut output| {
3445                output.redact_proprietary(PROPRIETARY_OUTPUT_INFO);
3446            });
3447        })
3448        .redact_orchard_with(redact_orchard_bundle)
3449        .redact_ironwood_with(redact_orchard_bundle)
3450        .finish()
3451}
3452
3453#[cfg(feature = "pczt")]
3454fn compact_signer_view(pczt: &pczt::Pczt) -> pczt::Pczt {
3455    let redact_v6_anchors = *pczt.global().tx_version() == zcash_protocol::constants::V6_TX_VERSION;
3456
3457    fn redact_orchard_bundle(
3458        mut redactor: pczt::roles::redactor::orchard::OrchardRedactor<'_>,
3459        redact_v6_anchor: bool,
3460    ) {
3461        redactor.clear_zkproof();
3462        redactor.clear_bsk();
3463        redactor.redact_actions(|mut action| {
3464            action.clear_spend_witness();
3465            action.clear_spend_dummy_sk();
3466            action.redact_output_proprietary(PROPRIETARY_OUTPUT_INFO);
3467        });
3468
3469        redactor.compact_resolvable_fields();
3470
3471        if redact_v6_anchor {
3472            redactor.clear_anchor();
3473        }
3474    }
3475
3476    Redactor::new(pczt.clone())
3477        .redact_global_with(|mut redactor| {
3478            redactor.redact_proprietary(PROPRIETARY_PROPOSAL_INFO);
3479        })
3480        .redact_transparent_with(|mut redactor| {
3481            redactor.redact_outputs(|mut output| {
3482                output.redact_proprietary(PROPRIETARY_OUTPUT_INFO);
3483            });
3484        })
3485        .redact_sapling_with(|mut redactor| {
3486            redactor.clear_bsk();
3487            redactor.redact_spends(|mut spend| {
3488                spend.clear_zkproof();
3489                spend.clear_dummy_ask();
3490            });
3491            redactor.redact_outputs(|mut output| {
3492                output.clear_zkproof();
3493                output.redact_proprietary(PROPRIETARY_OUTPUT_INFO);
3494            });
3495            if redact_v6_anchors {
3496                redactor.clear_anchor();
3497            }
3498        })
3499        .redact_orchard_with(|redactor| {
3500            redact_orchard_bundle(redactor, redact_v6_anchors);
3501        })
3502        .redact_ironwood_with(|redactor| {
3503            redact_orchard_bundle(redactor, redact_v6_anchors);
3504        })
3505        .finish()
3506}
3507
3508/// Creates a compact wallet PCZT for a batch Signer that obtains its full viewing key
3509/// independently and returns only new Orchard and Ironwood signatures.
3510///
3511/// In addition to the [`SignerView::Compact`] policy of [`redact_pczt_for_signer`],
3512/// this removes spend full viewing keys and existing signatures. It also removes the
3513/// randomizer from actions already authorized in `pczt`, while unsigned actions such as
3514/// wallet controlled zero value spends retain theirs. Sapling signatures require a
3515/// separate signing path.
3516///
3517/// The caller must retain the authoritative PCZT and apply the returned signature
3518/// contributions to it; the returned view is not independently extractable. This
3519/// function must run before its existing signatures are redacted. The returned view
3520/// cannot be signed with the high level
3521/// [`pczt::roles::signer::Signer`] because that role expects full viewing keys.
3522#[cfg(feature = "pczt")]
3523pub fn redact_pczt_for_batch_signer(pczt: &pczt::Pczt) -> pczt::Pczt {
3524    // Snapshot existing signature positions before the batch view clears them.
3525    fn preauthorized_action_indices(bundle: &pczt::orchard::Bundle) -> Vec<usize> {
3526        bundle
3527            .actions()
3528            .iter()
3529            .enumerate()
3530            .filter_map(|(index, action)| {
3531                action.spend().spend_auth_sig().is_some().then_some(index)
3532            })
3533            .collect()
3534    }
3535
3536    fn redact_orchard_bundle(
3537        mut redactor: pczt::roles::redactor::orchard::OrchardRedactor<'_>,
3538        preauthorized_action_indices: &[usize],
3539    ) {
3540        // The batch Signer derives its FVK and returns only new signatures. Existing
3541        // signatures MUST be omitted: deployed batch Signers reject requests that
3542        // already carry signatures. Omitting them strands nothing — this view is a
3543        // signing request, not the authoritative PCZT, and the caller's authoritative
3544        // copy retains them.
3545        redactor.redact_actions(|mut action| {
3546            action.clear_spend_fvk();
3547            action.clear_spend_auth_sig();
3548        });
3549        // Unsigned actions keep alpha, including wallet controlled zero value spends.
3550        for &index in preauthorized_action_indices {
3551            redactor.redact_action(index, |mut action| action.clear_spend_alpha());
3552        }
3553    }
3554
3555    let orchard_preauthorized = preauthorized_action_indices(pczt.orchard());
3556    let ironwood_preauthorized = preauthorized_action_indices(pczt.ironwood());
3557
3558    // Apply the policy shared by every external Signer first.
3559    Redactor::new(redact_pczt_for_signer(pczt, SignerView::Compact))
3560        .redact_orchard_with(|redactor| {
3561            redact_orchard_bundle(redactor, &orchard_preauthorized);
3562        })
3563        .redact_ironwood_with(|redactor| {
3564            redact_orchard_bundle(redactor, &ironwood_preauthorized);
3565        })
3566        .finish()
3567}
3568
3569/// Finalizes the given PCZT, and persists the transaction to the wallet database.
3570///
3571/// The PCZT should have been created via [`create_pczt_from_proposal`], which adds
3572/// metadata necessary for the wallet backend.
3573///
3574/// Returns the transaction ID for the resulting transaction.
3575///
3576/// - `sapling_vk` is optional to allow the caller to check whether a PCZT has Sapling
3577///   with [`pczt::roles::prover::Prover::requires_sapling_proofs`], and avoid downloading
3578///   the Sapling parameters if they are not needed. If `sapling_vk` is `None`, and the
3579///   PCZT has a Sapling bundle, this function will return an error.
3580/// - `orchard_vk` is optional to allow the caller to control where the Orchard verifying
3581///   key is generated or cached. If `orchard_vk` is `None`, and the PCZT has an Orchard
3582///   bundle, an Orchard verifying key will be generated on the fly.
3583///
3584/// `rng` supplies the randomness for the binding signatures and for verifying the extracted
3585/// transaction.
3586#[cfg(feature = "pczt")]
3587pub fn extract_and_store_transaction_from_pczt<DbT, N>(
3588    wallet_db: &mut DbT,
3589    clock: &impl Clock,
3590    rng: &mut impl CryptoRng,
3591    pczt: pczt::Pczt,
3592    sapling_vk: Option<(
3593        &sapling::circuit::SpendVerifyingKey,
3594        &sapling::circuit::OutputVerifyingKey,
3595    )>,
3596    #[cfg(feature = "orchard")] orchard_vk: Option<&orchard::circuit::VerifyingKey>,
3597) -> Result<TxId, ExtractErrT<DbT, N>>
3598where
3599    DbT: WalletWrite + WalletCommitmentTrees,
3600    <DbT as WalletRead>::AccountId: serde::de::DeserializeOwned,
3601{
3602    let finalized = SpendFinalizer::new(pczt).finalize_spends()?;
3603
3604    let proposal_info = finalized
3605        .global()
3606        .proprietary()
3607        .get(PROPRIETARY_PROPOSAL_INFO)
3608        .ok_or_else(|| PcztError::Invalid("PCZT missing proprietary proposal info field".into()))
3609        .and_then(|v| {
3610            postcard::from_bytes::<ProposalInfo<<DbT as WalletRead>::AccountId>>(v).map_err(|e| {
3611                PcztError::Invalid(format!(
3612                    "Postcard decoding of proprietary proposal info failed: {e}"
3613                ))
3614            })
3615        })?;
3616
3617    // The per-action sent-output recovery shared by the Orchard and Ironwood bundles, which
3618    // differ only in the note plaintext version their notes carry (`V2` for Orchard, `V3` —
3619    // ZIP 2005 — for Ironwood): the note reconstructed from the PCZT's output fields, zipped
3620    // with the recipient metadata `create_pczt_from_proposal` attached to the output.
3621    #[allow(clippy::type_complexity)]
3622    fn orchard_protocol_output_info<AccountId: serde::de::DeserializeOwned>(
3623        actions: &[pczt::orchard::Action],
3624        note_version: orchard::note::NoteVersion,
3625    ) -> Result<
3626        Vec<
3627            Option<(
3628                (PcztRecipient<AccountId>, Option<ZcashAddress>),
3629                orchard::Note,
3630            )>,
3631        >,
3632        PcztError,
3633    > {
3634        actions
3635            .iter()
3636            .map(|act| {
3637                let note = || {
3638                    let recipient = act.output().recipient().as_ref().and_then(|b| {
3639                        ::orchard::Address::from_raw_address_bytes(b).into_option()
3640                    })?;
3641                    let value = act
3642                        .output()
3643                        .value()
3644                        .map(orchard::value::NoteValue::from_raw)?;
3645                    let rho =
3646                        orchard::note::Rho::from_bytes(act.spend().nullifier()).into_option()?;
3647                    let rseed = act.output().rseed().as_ref().and_then(|rseed| {
3648                        orchard::note::RandomSeed::from_bytes(*rseed, &rho).into_option()
3649                    })?;
3650
3651                    orchard::Note::from_parts(recipient, value, rho, rseed, note_version)
3652                        .into_option()
3653                };
3654
3655                let external_address = act
3656                    .output()
3657                    .user_address()
3658                    .as_deref()
3659                    .map(ZcashAddress::try_from_encoded)
3660                    .transpose()
3661                    .map_err(|e| PcztError::Invalid(format!("Invalid user_address: {e}")))?;
3662
3663                let pczt_recipient = act
3664                    .output()
3665                    .proprietary()
3666                    .get(PROPRIETARY_OUTPUT_INFO)
3667                    .map(|v| postcard::from_bytes::<PcztRecipient<AccountId>>(v))
3668                    .transpose()
3669                    .map_err(|e: postcard::Error| {
3670                        PcztError::Invalid(format!(
3671                            "Postcard decoding of proprietary output info failed: {e}"
3672                        ))
3673                    })?
3674                    .map(|pczt_recipient| (pczt_recipient, external_address));
3675
3676                // If the pczt recipient is not present, this is a dummy note; if the note is not
3677                // present, then the PCZT has been pruned to make this output unrecoverable and so
3678                // we also ignore it.
3679                Ok(pczt_recipient.zip(note()))
3680            })
3681            .collect::<Result<Vec<_>, PcztError>>()
3682    }
3683
3684    let orchard_output_info = orchard_protocol_output_info::<<DbT as WalletRead>::AccountId>(
3685        finalized.orchard().actions(),
3686        orchard::note::NoteVersion::V2,
3687    )?;
3688
3689    let ironwood_output_info = orchard_protocol_output_info::<<DbT as WalletRead>::AccountId>(
3690        finalized.ironwood().actions(),
3691        orchard::note::NoteVersion::V3,
3692    )?;
3693
3694    let sapling_output_info = finalized
3695        .sapling()
3696        .outputs()
3697        .iter()
3698        .map(|out| {
3699            let note = || {
3700                let recipient = out
3701                    .recipient()
3702                    .as_ref()
3703                    .and_then(::sapling::PaymentAddress::from_bytes)?;
3704                let value = out.value().map(::sapling::value::NoteValue::from_raw)?;
3705                let rseed = out
3706                    .rseed()
3707                    .as_ref()
3708                    .cloned()
3709                    .map(::sapling::note::Rseed::AfterZip212)?;
3710
3711                Some(::sapling::Note::from_parts(recipient, value, rseed))
3712            };
3713
3714            let external_address = out
3715                .user_address()
3716                .as_deref()
3717                .map(ZcashAddress::try_from_encoded)
3718                .transpose()
3719                .map_err(|e| PcztError::Invalid(format!("Invalid user_address: {e}")))?;
3720
3721            let pczt_recipient = out
3722                .proprietary()
3723                .get(PROPRIETARY_OUTPUT_INFO)
3724                .map(|v| postcard::from_bytes::<PcztRecipient<<DbT as WalletRead>::AccountId>>(v))
3725                .transpose()
3726                .map_err(|e: postcard::Error| {
3727                    PcztError::Invalid(format!(
3728                        "Postcard decoding of proprietary output info failed: {e}"
3729                    ))
3730                })?
3731                .map(|pczt_recipient| (pczt_recipient, external_address));
3732
3733            // If the pczt recipient is not present, this is a dummy note; if the note is not
3734            // present, then the PCZT has been pruned to make this output unrecoverable and so we
3735            // also ignore it.
3736            Ok(pczt_recipient.zip(note()))
3737        })
3738        .collect::<Result<Vec<_>, PcztError>>()?;
3739
3740    let transparent_output_info = finalized
3741        .transparent()
3742        .outputs()
3743        .iter()
3744        .map(|out| {
3745            let external_address = out
3746                .user_address()
3747                .as_deref()
3748                .map(ZcashAddress::try_from_encoded)
3749                .transpose()
3750                .map_err(|e| PcztError::Invalid(format!("Invalid user_address: {e}")))?;
3751
3752            let pczt_recipient = out
3753                .proprietary()
3754                .get(PROPRIETARY_OUTPUT_INFO)
3755                .map(|v| postcard::from_bytes::<PcztRecipient<<DbT as WalletRead>::AccountId>>(v))
3756                .transpose()
3757                .map_err(|e: postcard::Error| {
3758                    PcztError::Invalid(format!(
3759                        "Postcard decoding of proprietary output info failed: {e}"
3760                    ))
3761                })?
3762                .map(|pczt_recipient| (pczt_recipient, external_address));
3763
3764            Ok(pczt_recipient)
3765        })
3766        .collect::<Result<Vec<_>, PcztError>>()?;
3767
3768    let utxos_map = finalized
3769        .transparent()
3770        .inputs()
3771        .iter()
3772        .map(|input| {
3773            Zatoshis::from_u64(*input.value()).map(|value| {
3774                (
3775                    OutPoint::new(*input.prevout_txid(), *input.prevout_index()),
3776                    value,
3777                )
3778            })
3779        })
3780        .collect::<Result<BTreeMap<_, _>, _>>()?;
3781
3782    let mut tx_extractor = TransactionExtractor::new(finalized);
3783    if let Some((spend_vk, output_vk)) = sapling_vk {
3784        tx_extractor = tx_extractor.with_sapling(spend_vk, output_vk);
3785    }
3786    if let Some(orchard_vk) = orchard_vk {
3787        tx_extractor = tx_extractor.with_orchard(orchard_vk);
3788    }
3789    let transaction = tx_extractor.extract(rng)?;
3790    let txid = transaction.txid();
3791
3792    #[allow(clippy::too_many_arguments)]
3793    fn to_sent_transaction_output<
3794        AccountId: Copy,
3795        D: Domain,
3796        O: ShieldedOutput<D>,
3797        DbT: WalletRead + WalletCommitmentTrees,
3798        N,
3799    >(
3800        domain: D,
3801        note: D::Note,
3802        output: &O,
3803        output_pool: ShieldedPool,
3804        output_index: usize,
3805        pczt_recipient: PcztRecipient<AccountId>,
3806        external_address: Option<ZcashAddress>,
3807        note_value: impl Fn(&D::Note) -> u64,
3808        memo_bytes: impl Fn(&D::Memo) -> &[u8; 512],
3809        wallet_note: impl Fn(D::Note) -> Note,
3810    ) -> Result<SentTransactionOutput<AccountId>, ExtractErrT<DbT, N>> {
3811        let pk_d = D::get_pk_d(&note);
3812        let esk = D::derive_esk(&note).expect("notes are post-ZIP 212");
3813        let memo = try_output_recovery_with_pkd_esk(&domain, pk_d, esk, output).map(|(_, _, m)| {
3814            MemoBytes::from_bytes(memo_bytes(&m)).expect("Memo is the correct length.")
3815        });
3816
3817        // The note commitment tree an output's note is appended to is fully determined by the
3818        // pool the output was created in; recording it mirrors the transaction-builder path
3819        // (`create_proposed_transactions`), which tags every shielded sent output with its tree.
3820        let note_commitment_tree = match output_pool {
3821            ShieldedPool::Sapling => NoteCommitmentTree::Sapling,
3822            ShieldedPool::Orchard => NoteCommitmentTree::Orchard,
3823            ShieldedPool::Ironwood => NoteCommitmentTree::Ironwood,
3824        };
3825
3826        let note_value = Zatoshis::try_from(note_value(&note))?;
3827        let recipient = match (pczt_recipient, external_address) {
3828            (PcztRecipient::External, Some(addr)) => Ok(Recipient::External {
3829                recipient_address: addr,
3830                output_pool: PoolType::Shielded(output_pool),
3831            }),
3832            (PcztRecipient::External, None) => Err(PcztError::Invalid(
3833                "external recipient needs to have its user_address field set".into(),
3834            )),
3835            #[cfg(feature = "transparent-inputs")]
3836            (PcztRecipient::EphemeralTransparent { .. }, _) => Err(PcztError::Invalid(
3837                "shielded output cannot be EphemeralTransparent".into(),
3838            )),
3839            #[cfg(feature = "transparent-inputs")]
3840            (PcztRecipient::InternalTransparent { .. }, _) => Err(PcztError::Invalid(
3841                "shielded output cannot be InternalTransparent".into(),
3842            )),
3843            (PcztRecipient::InternalShielded { receiving_account }, external_address) => {
3844                Ok(Recipient::InternalShielded {
3845                    receiving_account,
3846                    external_address,
3847                    note: Box::new(wallet_note(note)),
3848                })
3849            }
3850        }?;
3851
3852        Ok(SentTransactionOutput::from_parts_in_tree(
3853            Some(note_commitment_tree),
3854            output_index,
3855            recipient,
3856            note_value,
3857            memo,
3858        ))
3859    }
3860
3861    #[cfg(feature = "orchard")]
3862    let orchard_outputs = transaction
3863        .orchard_bundle()
3864        .map(|bundle| {
3865            assert_eq!(bundle.actions().len(), orchard_output_info.len());
3866            bundle
3867                .actions()
3868                .iter()
3869                .zip(orchard_output_info)
3870                .enumerate()
3871                .filter_map(|(output_index, (action, output_info))| {
3872                    output_info.map(|((pczt_recipient, external_address), note)| {
3873                        let domain = OrchardDomain::for_action(action);
3874                        to_sent_transaction_output::<_, _, _, DbT, _>(
3875                            domain,
3876                            note,
3877                            action,
3878                            ShieldedPool::Orchard,
3879                            output_index,
3880                            pczt_recipient,
3881                            external_address,
3882                            |note| note.value().inner(),
3883                            |memo| memo,
3884                            |note| Note::Orchard {
3885                                note,
3886                                pool: orchard::ValuePool::Orchard,
3887                            },
3888                        )
3889                    })
3890                })
3891                .collect::<Result<Vec<_>, _>>()
3892        })
3893        .transpose()?;
3894
3895    #[cfg(feature = "orchard")]
3896    let ironwood_outputs = transaction
3897        .ironwood_bundle()
3898        .map(|bundle| {
3899            assert_eq!(bundle.actions().len(), ironwood_output_info.len());
3900            bundle
3901                .actions()
3902                .iter()
3903                .zip(ironwood_output_info)
3904                .enumerate()
3905                .filter_map(|(output_index, (action, output_info))| {
3906                    output_info.map(|((pczt_recipient, external_address), note)| {
3907                        let domain = IronwoodDomain::for_action(action);
3908                        to_sent_transaction_output::<_, _, _, DbT, _>(
3909                            domain,
3910                            note,
3911                            action,
3912                            ShieldedPool::Ironwood,
3913                            output_index,
3914                            pczt_recipient,
3915                            external_address,
3916                            |note| note.value().inner(),
3917                            |memo| memo,
3918                            |note| Note::Orchard {
3919                                note,
3920                                pool: orchard::ValuePool::Ironwood,
3921                            },
3922                        )
3923                    })
3924                })
3925                .collect::<Result<Vec<_>, _>>()
3926        })
3927        .transpose()?;
3928
3929    let sapling_outputs = transaction
3930        .sapling_bundle()
3931        .map(|bundle| {
3932            assert_eq!(bundle.shielded_outputs().len(), sapling_output_info.len());
3933            bundle
3934                .shielded_outputs()
3935                .iter()
3936                .zip(sapling_output_info)
3937                .enumerate()
3938                .filter_map(|(output_index, (action, output_info))| {
3939                    output_info.map(|((pczt_recipient, external_address), note)| {
3940                        let domain =
3941                            SaplingDomain::new(sapling::note_encryption::Zip212Enforcement::On);
3942                        to_sent_transaction_output::<_, _, _, DbT, _>(
3943                            domain,
3944                            note,
3945                            action,
3946                            ShieldedPool::Sapling,
3947                            output_index,
3948                            pczt_recipient,
3949                            external_address,
3950                            |note| note.value().inner(),
3951                            |memo| memo,
3952                            Note::Sapling,
3953                        )
3954                    })
3955                })
3956                .collect::<Result<Vec<_>, _>>()
3957        })
3958        .transpose()?;
3959
3960    #[allow(unused_variables)]
3961    let transparent_outputs = transaction
3962        .transparent_bundle()
3963        .map(|bundle| {
3964            assert_eq!(bundle.vout.len(), transparent_output_info.len());
3965            bundle
3966                .vout
3967                .iter()
3968                .zip(transparent_output_info)
3969                .enumerate()
3970                .filter_map(|(output_index, (output, output_info))| {
3971                    output_info.map(|(pczt_recipient, external_address)| {
3972                        // This assumes that transparent outputs are pushed onto `transparent_output_meta`
3973                        // with the same indices they have in the transaction's transparent outputs.
3974                        // We do not reorder transparent outputs; there is no reason to do so because it
3975                        // would not usefully improve privacy.
3976                        let outpoint = OutPoint::new(txid.into(), output_index as u32);
3977
3978                        let recipient = match (pczt_recipient, external_address) {
3979                            (PcztRecipient::External, Some(addr)) => {
3980                                Ok(Recipient::External {
3981                                    recipient_address: addr,
3982                                    output_pool: PoolType::Transparent,
3983                                })
3984                            }
3985                            (PcztRecipient::External, None) => Err(PcztError::Invalid(
3986                                "external recipient needs to have its user_address field set".into(),
3987                            )),
3988                            #[cfg(feature = "transparent-inputs")]
3989                            (PcztRecipient::EphemeralTransparent { receiving_account }, _) => output
3990                                .recipient_address()
3991                                .ok_or(PcztError::Invalid(
3992                                    "Ephemeral outputs cannot have a non-standard script_pubkey"
3993                                        .into(),
3994                                ))
3995                                .map(|ephemeral_address| Recipient::EphemeralTransparent {
3996                                    receiving_account,
3997                                    ephemeral_address,
3998                                    outpoint,
3999                                }),
4000                            #[cfg(feature = "transparent-inputs")]
4001                            (PcztRecipient::InternalTransparent { receiving_account }, _) => output
4002                                .recipient_address()
4003                                .ok_or(PcztError::Invalid(
4004                                    "Transparent change outputs cannot have a non-standard script_pubkey"
4005                                        .into(),
4006                                ))
4007                                .map(|recipient_address| Recipient::InternalTransparent {
4008                                    receiving_account,
4009                                    recipient_address,
4010                                }),
4011                            (
4012                                PcztRecipient::InternalShielded {
4013                                    receiving_account,
4014                                },
4015                                _,
4016                            ) => Err(PcztError::Invalid(
4017                                "Transparent output cannot be InternalShielded".into(),
4018                            )),
4019                        }?;
4020
4021                        Ok(SentTransactionOutput::from_parts(
4022                            output_index,
4023                            recipient,
4024                            output.value(),
4025                            None,
4026                        ))
4027                    })
4028                })
4029                .collect::<Result<Vec<_>, ExtractErrT<DbT, _>>>()
4030        })
4031        .transpose()?;
4032
4033    let mut outputs: Vec<SentTransactionOutput<_>> = vec![];
4034    #[cfg(feature = "orchard")]
4035    outputs.extend(orchard_outputs.into_iter().flatten());
4036    #[cfg(feature = "orchard")]
4037    outputs.extend(ironwood_outputs.into_iter().flatten());
4038    outputs.extend(sapling_outputs.into_iter().flatten());
4039    outputs.extend(transparent_outputs.into_iter().flatten());
4040
4041    let fee_amount = transaction
4042        .fee_paid(|outpoint| Ok::<_, BalanceError>(utxos_map.get(outpoint).copied()))?
4043        // We should never obtain a `None` result because we constructed the UTXOs map and the
4044        // transaction from the same PCZT.
4045        .expect("input map was constructed correctly");
4046
4047    // We don't need the spent UTXOs to be in transaction order.
4048    let utxos_spent = utxos_map.into_keys().collect::<Vec<_>>();
4049
4050    let created = time::OffsetDateTime::from(clock.now());
4051
4052    let transactions = vec![SentTransaction::new(
4053        &transaction,
4054        created,
4055        proposal_info.target_height,
4056        proposal_info.from_account,
4057        &outputs,
4058        fee_amount,
4059        #[cfg(feature = "transparent-inputs")]
4060        &utxos_spent,
4061    )];
4062
4063    wallet_db
4064        .store_transactions_to_be_sent(&transactions)
4065        .map_err(Error::DataSource)?;
4066
4067    Ok(txid)
4068}
4069
4070/// Constructs a transaction that consumes available transparent UTXOs belonging to the specified
4071/// secret key, and sends them to the most-preferred receiver of the default internal address for
4072/// the provided Unified Spending Key.
4073///
4074/// This procedure will not attempt to shield transparent funds if the total amount being shielded
4075/// is less than the default fee to send the transaction. Fees will be paid only from the
4076/// transparent UTXOs being consumed.
4077///
4078/// Parameters:
4079/// * `wallet_db`: A read/write reference to the wallet database
4080/// * `params`: Consensus parameters
4081/// * `rng`: The source of randomness used to construct, prove, and sign the transaction.
4082/// * `spend_prover`: The [`sapling::SpendProver`] to use in constructing the shielded
4083///   transaction.
4084/// * `output_prover`: The [`sapling::OutputProver`] to use in constructing the shielded
4085///   transaction.
4086/// * `input_selector`: The [`InputSelector`] to for note selection and change and fee
4087///   determination
4088/// * `usk`: The unified spending key that will be used to detect and spend transparent UTXOs,
4089///   and that will provide the shielded address to which funds will be sent. Funds will be
4090///   shielded to the internal (change) address associated with the most preferred shielded
4091///   receiver corresponding to this account, or if no shielded receiver can be used for this
4092///   account, this function will return an error. This procedure will return an error if the
4093///   USK does not correspond to an account known to the wallet.
4094/// * `from_addrs`: The list of transparent addresses that will be used to filter transaparent
4095///   UTXOs received by the wallet. Only UTXOs received at one of the provided addresses will
4096///   be selected to be shielded.
4097/// * `min_confirmations`: The minimum number of confirmations that a previously
4098///   received note must have in the blockchain in order to be considered for being
4099///   spent. A value of 10 confirmations is recommended and 0-conf transactions are
4100///   not supported.
4101///
4102/// [`sapling::SpendProver`]: sapling::prover::SpendProver
4103/// [`sapling::OutputProver`]: sapling::prover::OutputProver
4104#[cfg(feature = "transparent-inputs")]
4105#[allow(clippy::too_many_arguments)]
4106#[allow(clippy::type_complexity)]
4107pub fn shield_transparent_funds<DbT, ParamsT, InputsT, ChangeT>(
4108    wallet_db: &mut DbT,
4109    params: &ParamsT,
4110    clock: &impl Clock,
4111    rng: &mut impl CryptoRng,
4112    spend_prover: &impl SpendProver,
4113    output_prover: &impl OutputProver,
4114    input_selector: &InputsT,
4115    change_strategy: &ChangeT,
4116    shielding_threshold: Zatoshis,
4117    spending_keys: &SpendingKeys,
4118    from_addrs: &[TransparentAddress],
4119    to_account: <DbT as InputSource>::AccountId,
4120    confirmations_policy: ConfirmationsPolicy,
4121) -> Result<NonEmpty<TxId>, ShieldErrT<DbT, InputsT, ChangeT>>
4122where
4123    ParamsT: consensus::Parameters,
4124    DbT: WalletWrite + WalletCommitmentTrees + InputSource<Error = <DbT as WalletRead>::Error>,
4125    InputsT: ShieldingSelector<InputSource = DbT>,
4126    ChangeT: ChangeStrategy<MetaSource = DbT>,
4127{
4128    let proposal = propose_shielding(
4129        wallet_db,
4130        params,
4131        input_selector,
4132        change_strategy,
4133        shielding_threshold,
4134        from_addrs,
4135        to_account,
4136        confirmations_policy,
4137        CoinbaseFilter::AllTransparentOutputs,
4138        None,
4139    )?;
4140
4141    create_proposed_transactions(
4142        wallet_db,
4143        params,
4144        clock,
4145        rng,
4146        spend_prover,
4147        output_prover,
4148        spending_keys,
4149        OvkPolicy::Sender,
4150        &proposal,
4151        None,
4152    )
4153}
4154
4155#[cfg(test)]
4156mod tests {
4157    use super::*;
4158
4159    use zcash_protocol::zip318::{AnchorBucketInterval, BlockSpacing};
4160
4161    fn policy(confirmations: u32) -> ConfirmationsPolicy {
4162        ConfirmationsPolicy::new_symmetrical_unchecked(
4163            confirmations,
4164            #[cfg(feature = "transparent-inputs")]
4165            false,
4166        )
4167    }
4168
4169    fn target(height: u32) -> TargetHeight {
4170        TargetHeight::from(BlockHeight::from_u32(height))
4171    }
4172
4173    /// Bucketing raises the confirmation requirement so that the resulting anchor lands exactly on
4174    /// a grid boundary. Because the anchor and the note-eligibility bound are the same quantity,
4175    /// this is what guarantees every selected note has a witness at that anchor.
4176    #[test]
4177    fn bucketed_policy_lands_the_anchor_on_a_boundary() {
4178        let interval = AnchorGrid::new(AnchorBucketInterval::ZIP_318, BlockSpacing::UNIFORM);
4179        let policy = policy(10);
4180
4181        for height in [2_000_000u32, 2_000_143, 2_000_144, 1_000_000] {
4182            let target = target(height);
4183            let bucketed = policy
4184                .bucketed(interval, target, BlockHeight::from_u32(1))
4185                .expect("a boundary is reachable well above the grid's origin");
4186            let anchor = bucketed.anchor_height(target);
4187
4188            assert!(
4189                interval.is_boundary(anchor),
4190                "anchor {anchor:?} off the grid"
4191            );
4192            // Age 1: one interval BELOW the most recent boundary, never the most recent itself.
4193            let most_recent = interval.boundary_at_or_below(policy.anchor_height(target));
4194            assert_eq!(
4195                u32::from(anchor),
4196                u32::from(most_recent) - interval.interval().block_count().get(),
4197                "the chosen boundary must be exactly one interval below the most recent"
4198            );
4199            assert!(anchor < most_recent, "an age of 0 is not admissible");
4200            // Never fewer confirmations than asked for, and the invariant is preserved.
4201            assert!(bucketed.trusted() >= policy.trusted());
4202            assert!(bucketed.untrusted() >= bucketed.trusted());
4203            // The extra wait is bounded by two intervals: up to one to reach the most recent
4204            // boundary, plus the one that age 1 steps back.
4205            assert!(
4206                u32::from(bucketed.trusted()) - u32::from(policy.trusted())
4207                    < 2 * interval.interval().block_count().get()
4208            );
4209        }
4210    }
4211
4212    /// Even an anchor already sitting on a boundary steps back one interval: that boundary is the
4213    /// most recent, and ZIP 318 admits only ages of 1 or more.
4214    #[test]
4215    fn bucketing_never_chooses_the_most_recent_boundary() {
4216        let interval = AnchorGrid::new(AnchorBucketInterval::ZIP_318, BlockSpacing::UNIFORM);
4217        let policy = policy(10);
4218        let aligned_anchor = interval.boundary_at_or_below(BlockHeight::from_u32(2_000_000));
4219        let target = target(u32::from(aligned_anchor) + 10);
4220
4221        let bucketed = policy
4222            .bucketed(interval, target, BlockHeight::from_u32(1))
4223            .expect("reachable");
4224        assert_eq!(
4225            u32::from(bucketed.anchor_height(target)),
4226            u32::from(aligned_anchor) - interval.interval().block_count().get()
4227        );
4228        assert!(bucketed.trusted() > policy.trusted());
4229    }
4230
4231    /// Below the first boundary there is no grid to bucket to, and the policy declines rather than
4232    /// producing a zero or negative confirmation count.
4233    #[test]
4234    fn bucketing_declines_below_the_first_boundary() {
4235        let interval = AnchorGrid::new(AnchorBucketInterval::ZIP_318, BlockSpacing::UNIFORM);
4236        let policy = policy(10);
4237        // The ordinary anchor is 90, whose boundary-at-or-below is 0; there is no boundary an
4238        // interval below that, so no admissible anchor exists.
4239        assert!(
4240            policy
4241                .bucketed(interval, target(100), BlockHeight::from_u32(1))
4242                .is_none()
4243        );
4244    }
4245
4246    /// A shorter test-network interval buckets on its own grid.
4247    #[test]
4248    fn bucketing_honours_a_custom_interval() {
4249        let interval = AnchorGrid::new(
4250            AnchorBucketInterval::custom(NonZeroU32::new(12).expect("nonzero")),
4251            BlockSpacing::UNIFORM,
4252        );
4253        let policy = policy(3);
4254        let target = target(1_000);
4255
4256        let bucketed = policy
4257            .bucketed(interval, target, BlockHeight::from_u32(1))
4258            .expect("reachable");
4259        let anchor = bucketed.anchor_height(target);
4260        assert!(interval.is_boundary(anchor));
4261        // Most recent boundary at or below 997 is 996; age 1 steps back to 984.
4262        assert_eq!(u32::from(anchor), 984);
4263    }
4264
4265    /// From NU7 activation the grid's boundaries are three times as far apart in blocks, and
4266    /// bucketing steps back one boundary of the scaled grid.
4267    #[test]
4268    fn bucketing_follows_the_scaled_grid_from_nu7() {
4269        let activation = 1_000_000u32;
4270        let grid = AnchorGrid::new(
4271            AnchorBucketInterval::ZIP_318,
4272            BlockSpacing::with_nu7_activation(BlockHeight::from_u32(activation)),
4273        );
4274        let scaled_interval = consensus::NU7_POW_TARGET_SPACING_RATIO
4275            * AnchorBucketInterval::ZIP_318.block_count().get();
4276        let policy = policy(10);
4277        let target = target(2_000_000);
4278
4279        let bucketed = policy
4280            .bucketed(grid, target, BlockHeight::from_u32(1))
4281            .expect("reachable");
4282        let anchor = u32::from(bucketed.anchor_height(target));
4283        let most_recent = u32::from(grid.boundary_at_or_below(policy.anchor_height(target)));
4284        assert_eq!(most_recent % scaled_interval, 0);
4285        assert_eq!(anchor, most_recent - scaled_interval);
4286    }
4287}