Skip to main content

zcash_client_backend/
proto.rs

1//! This module contains generated code for handling light client protobuf structs.
2
3use incrementalmerkletree::frontier::CommitmentTree;
4use nonempty::NonEmpty;
5use std::{
6    array::TryFromSliceError,
7    collections::BTreeMap,
8    fmt::{self, Display},
9    io,
10    num::NonZeroU32,
11};
12use zcash_address::unified::{self, Encoding};
13
14use self::proposal::proposed_input;
15// `parse_standard_proposal` matches the input value's variants bare.
16use self::proposal::proposed_input::Value::*;
17use self::proposal::{PriorStepChange, PriorStepOutput, ReceivedOutput};
18
19use sapling::{self, Node, note::ExtractedNoteCommitment};
20use zcash_note_encryption::EphemeralKeyBytes;
21use zcash_primitives::{
22    block::{BlockHash, BlockHeader},
23    merkle_tree::read_commitment_tree,
24    transaction::{TxId, TxVersion},
25};
26use zcash_protocol::{
27    PoolType, ShieldedPool,
28    consensus::{self, BlockHeight, NetworkType},
29    memo::{self, MemoBytes},
30    value::Zatoshis,
31};
32use zip321::{TransactionRequest, Zip321Error};
33
34use crate::{
35    data_api::{
36        InputSource,
37        chain::ChainState,
38        wallet::{ConfirmationsPolicy, TargetHeight, input_selection::LockFilter},
39    },
40    fees::{ChangeValue, DummyOutputCounts, StandardFeeRule, TransactionBalance},
41    proposal::{
42        Proposal, ProposalError, ShieldedInputs, Step, StepOutput, StepOutputIndex,
43        produces_shielded_bundle,
44    },
45};
46
47#[cfg(feature = "transparent-inputs")]
48use transparent::bundle::OutPoint;
49
50#[cfg(feature = "orchard")]
51use orchard::tree::MerkleHashOrchard;
52
53#[rustfmt::skip]
54#[allow(unknown_lints)]
55#[allow(clippy::derive_partial_eq_without_eq)]
56#[allow(clippy::doc_overindented_list_items)]
57pub mod compact_formats;
58
59#[rustfmt::skip]
60#[allow(unknown_lints)]
61#[allow(clippy::derive_partial_eq_without_eq)]
62#[allow(clippy::doc_overindented_list_items)]
63pub mod proposal;
64
65#[rustfmt::skip]
66#[allow(unknown_lints)]
67#[allow(clippy::derive_partial_eq_without_eq)]
68#[allow(clippy::doc_overindented_list_items)]
69pub mod service;
70
71impl compact_formats::CompactBlock {
72    /// Returns the [`BlockHash`] for this block.
73    ///
74    /// # Panics
75    ///
76    /// This function will panic if [`field@Self::header`] is not set and
77    /// [`field@Self::hash`] is not exactly 32 bytes.
78    pub fn hash(&self) -> BlockHash {
79        if let Some(header) = self.header() {
80            header.hash()
81        } else {
82            BlockHash::from_slice(&self.hash)
83        }
84    }
85
86    /// Returns the [`BlockHash`] for this block's parent.
87    ///
88    /// # Panics
89    ///
90    /// This function will panic if [`field@Self::header`] is not set and
91    /// [`field@Self::prev_hash`] is not exactly 32 bytes.
92    pub fn prev_hash(&self) -> BlockHash {
93        if let Some(header) = self.header() {
94            header.prev_block
95        } else {
96            BlockHash::from_slice(&self.prev_hash)
97        }
98    }
99
100    /// Returns the [`BlockHeight`] value for this block
101    ///
102    /// # Panics
103    ///
104    /// This function will panic if [`field@Self::height`] is not representable within a
105    /// `u32`.
106    pub fn height(&self) -> BlockHeight {
107        self.height.try_into().unwrap()
108    }
109
110    /// Returns the [`BlockHeader`] for this block if present.
111    ///
112    /// A convenience method that parses [`field@Self::header`] if present.
113    pub fn header(&self) -> Option<BlockHeader> {
114        if self.header.is_empty() {
115            None
116        } else {
117            BlockHeader::read(&self.header[..]).ok()
118        }
119    }
120}
121
122impl compact_formats::CompactTx {
123    /// Returns the transaction Id
124    pub fn txid(&self) -> TxId {
125        let mut txid_bytes = [0u8; 32];
126        txid_bytes.copy_from_slice(&self.txid);
127        TxId::from_bytes(txid_bytes)
128    }
129}
130
131/// An error indicating that a field of a compact format structure could not be parsed.
132#[derive(Clone, Debug)]
133#[non_exhaustive]
134pub enum CompactFormatError {
135    /// A byte slice had an invalid length for the expected field.
136    InvalidLength(TryFromSliceError),
137    /// A field value did not represent a valid protocol element.
138    InvalidValue,
139}
140
141impl Display for CompactFormatError {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            CompactFormatError::InvalidLength(e) => write!(f, "Invalid compact format field: {e}"),
145            CompactFormatError::InvalidValue => {
146                write!(f, "Compact format field is not a valid protocol element")
147            }
148        }
149    }
150}
151
152impl compact_formats::CompactSaplingOutput {
153    /// Returns the note commitment for this output.
154    ///
155    /// A convenience method that parses [`field@Self::cmu`].
156    pub fn cmu(&self) -> Result<ExtractedNoteCommitment, CompactFormatError> {
157        let mut repr = [0; 32];
158        repr.copy_from_slice(&self.cmu[..]);
159        Option::from(ExtractedNoteCommitment::from_bytes(&repr))
160            .ok_or(CompactFormatError::InvalidValue)
161    }
162
163    /// Returns the ephemeral public key for this output.
164    ///
165    /// A convenience method that parses [`field@Self::ephemeral_key`].
166    pub fn ephemeral_key(&self) -> Result<EphemeralKeyBytes, CompactFormatError> {
167        self.ephemeral_key[..]
168            .try_into()
169            .map(EphemeralKeyBytes)
170            .map_err(CompactFormatError::InvalidLength)
171    }
172}
173
174impl<Proof> From<&sapling::bundle::OutputDescription<Proof>>
175    for compact_formats::CompactSaplingOutput
176{
177    fn from(
178        out: &sapling::bundle::OutputDescription<Proof>,
179    ) -> compact_formats::CompactSaplingOutput {
180        compact_formats::CompactSaplingOutput {
181            cmu: out.cmu().to_bytes().to_vec(),
182            ephemeral_key: out.ephemeral_key().as_ref().to_vec(),
183            ciphertext: out.enc_ciphertext()[..sapling::note_encryption::COMPACT_NOTE_SIZE]
184                .to_vec(),
185        }
186    }
187}
188
189impl TryFrom<compact_formats::CompactSaplingOutput>
190    for sapling::note_encryption::CompactOutputDescription
191{
192    type Error = CompactFormatError;
193
194    fn try_from(value: compact_formats::CompactSaplingOutput) -> Result<Self, Self::Error> {
195        (&value).try_into()
196    }
197}
198
199impl TryFrom<&compact_formats::CompactSaplingOutput>
200    for sapling::note_encryption::CompactOutputDescription
201{
202    type Error = CompactFormatError;
203
204    fn try_from(value: &compact_formats::CompactSaplingOutput) -> Result<Self, Self::Error> {
205        Ok(sapling::note_encryption::CompactOutputDescription {
206            cmu: value.cmu()?,
207            ephemeral_key: value.ephemeral_key()?,
208            enc_ciphertext: value.ciphertext[..]
209                .try_into()
210                .map_err(CompactFormatError::InvalidLength)?,
211        })
212    }
213}
214
215impl compact_formats::CompactSaplingSpend {
216    /// Returns the nullifier for this spend.
217    ///
218    /// A convenience method that parses [`field@Self::nf`].
219    pub fn nf(&self) -> Result<sapling::Nullifier, CompactFormatError> {
220        sapling::Nullifier::from_slice(&self.nf).map_err(CompactFormatError::InvalidLength)
221    }
222}
223
224#[cfg(feature = "transparent-inputs")]
225impl compact_formats::CompactTxIn {
226    /// Returns the outpoint of the transparent output that this input spends.
227    ///
228    /// A convenience method that parses [`field@Self::prevout_txid`] and
229    /// [`field@Self::prevout_index`].
230    pub fn prevout(&self) -> Result<transparent::bundle::OutPoint, CompactFormatError> {
231        Ok(transparent::bundle::OutPoint::new(
232            self.prevout_txid[..]
233                .try_into()
234                .map_err(CompactFormatError::InvalidLength)?,
235            self.prevout_index,
236        ))
237    }
238}
239
240#[cfg(feature = "transparent-inputs")]
241impl compact_formats::TxOut {
242    /// Returns the transparent output that this message describes.
243    ///
244    /// A convenience method that parses [`field@Self::value`] and [`field@Self::script_pub_key`].
245    ///
246    /// # Errors
247    /// Returns [`CompactFormatError::InvalidValue`] if the value is not a valid amount of
248    /// zatoshis.
249    pub fn to_txout(&self) -> Result<transparent::bundle::TxOut, CompactFormatError> {
250        Ok(transparent::bundle::TxOut::new(
251            Zatoshis::from_u64(self.value).map_err(|_| CompactFormatError::InvalidValue)?,
252            transparent::address::Script(zcash_script::script::Code(self.script_pub_key.clone())),
253        ))
254    }
255}
256
257#[cfg(feature = "orchard")]
258impl TryFrom<&compact_formats::CompactOrchardAction> for orchard::note_encryption::CompactAction {
259    type Error = CompactFormatError;
260
261    fn try_from(value: &compact_formats::CompactOrchardAction) -> Result<Self, Self::Error> {
262        Ok(orchard::note_encryption::CompactAction::from_parts(
263            value.nf()?,
264            value.cmx()?,
265            value.ephemeral_key()?,
266            value.ciphertext[..]
267                .try_into()
268                .map_err(CompactFormatError::InvalidLength)?,
269        ))
270    }
271}
272
273#[cfg(feature = "orchard")]
274impl compact_formats::CompactOrchardAction {
275    /// Returns the note commitment for the output of this action.
276    ///
277    /// A convenience method that parses [`field@Self::cmx`].
278    pub fn cmx(&self) -> Result<orchard::note::ExtractedNoteCommitment, CompactFormatError> {
279        Option::from(orchard::note::ExtractedNoteCommitment::from_bytes(
280            &self.cmx[..]
281                .try_into()
282                .map_err(CompactFormatError::InvalidLength)?,
283        ))
284        .ok_or(CompactFormatError::InvalidValue)
285    }
286
287    /// Returns the nullifier for the spend of this action.
288    ///
289    /// A convenience method that parses [`field@Self::nullifier`].
290    pub fn nf(&self) -> Result<orchard::note::Nullifier, CompactFormatError> {
291        let nf_bytes: [u8; 32] = self.nullifier[..]
292            .try_into()
293            .map_err(CompactFormatError::InvalidLength)?;
294        Option::from(orchard::note::Nullifier::from_bytes(&nf_bytes))
295            .ok_or(CompactFormatError::InvalidValue)
296    }
297
298    /// Returns the ephemeral public key for the output of this action.
299    ///
300    /// A convenience method that parses [`field@Self::ephemeral_key`].
301    pub fn ephemeral_key(&self) -> Result<EphemeralKeyBytes, CompactFormatError> {
302        self.ephemeral_key[..]
303            .try_into()
304            .map(EphemeralKeyBytes)
305            .map_err(CompactFormatError::InvalidLength)
306    }
307}
308
309impl<A: sapling::bundle::Authorization> From<&sapling::bundle::SpendDescription<A>>
310    for compact_formats::CompactSaplingSpend
311{
312    fn from(spend: &sapling::bundle::SpendDescription<A>) -> compact_formats::CompactSaplingSpend {
313        compact_formats::CompactSaplingSpend {
314            nf: spend.nullifier().to_vec(),
315        }
316    }
317}
318
319#[cfg(feature = "orchard")]
320impl<SpendAuth> From<&orchard::Action<SpendAuth>> for compact_formats::CompactOrchardAction {
321    fn from(action: &orchard::Action<SpendAuth>) -> compact_formats::CompactOrchardAction {
322        compact_formats::CompactOrchardAction {
323            nullifier: action.nullifier().to_bytes().to_vec(),
324            cmx: action.cmx().to_bytes().to_vec(),
325            ephemeral_key: action.encrypted_note().epk_bytes.to_vec(),
326            ciphertext: action.encrypted_note().enc_ciphertext.0
327                [..orchard::note_encryption::COMPACT_NOTE_SIZE]
328                .to_vec(),
329        }
330    }
331}
332
333impl service::LightdInfo {
334    /// Returns the network type for the chain this server is following, or `None` if it
335    /// is not recognised.
336    pub fn chain_name(&self) -> Option<NetworkType> {
337        match self.chain_name.as_str() {
338            "main" => Some(NetworkType::Main),
339            "test" => Some(NetworkType::Test),
340            "regtest" => Some(NetworkType::Regtest),
341            _ => None,
342        }
343    }
344
345    /// Returns the Sapling activation height for the chain this server is following.
346    ///
347    /// # Panics
348    ///
349    /// This function will panic if [`field@Self::sapling_activation_height`] is not
350    /// representable within a `u32`.
351    pub fn sapling_activation_height(&self) -> BlockHeight {
352        self.sapling_activation_height
353            .try_into()
354            .expect("lightwalletd should provide in-range heights")
355    }
356
357    /// Returns the current consensus branch ID for the chain tip of the chain this server
358    /// is following, or `None` if it is not recognised.
359    pub fn consensus_branch_id(&self) -> Option<consensus::BranchId> {
360        u32::from_str_radix(&self.consensus_branch_id, 16)
361            .ok()?
362            .try_into()
363            .ok()
364    }
365
366    /// Returns the chain tip height reported by the full node backing this server.
367    ///
368    /// If the full node is still syncing, this may not be the network's chain tip; in
369    /// this case, [`Self::estimated_height`] will report a larger height.
370    ///
371    /// # Panics
372    ///
373    /// This function will panic if [`field@Self::block_height`] is not representable
374    /// within a `u32`.
375    pub fn block_height(&self) -> BlockHeight {
376        self.block_height
377            .try_into()
378            .expect("lightwalletd should provide in-range heights")
379    }
380
381    /// Returns the estimated chain tip height for the chain this server is following.
382    ///
383    /// If the full node backing this server is fully synced, this is always equal to
384    /// [`Self::block_height`].
385    ///
386    /// # Panics
387    ///
388    /// This function will panic if [`field@Self::estimated_height`] is not representable
389    /// within a `u32`.
390    pub fn estimated_height(&self) -> BlockHeight {
391        self.estimated_height
392            .try_into()
393            .expect("lightwalletd should provide in-range heights")
394    }
395
396    /// Returns the donation address for this server.
397    ///
398    /// Returns `None` if:
399    /// - no donation address was provided.
400    /// - the provided donation address is not a valid [`unified::Address`].
401    /// - the provided donation address is for a different chain.
402    pub fn donation_address(&self) -> Option<unified::Address> {
403        if self.donation_address.is_empty() {
404            None
405        } else {
406            let (network_type, _revision, address) =
407                unified::Address::decode(&self.donation_address).ok()?;
408            (Some(network_type) == self.chain_name()).then_some(address)
409        }
410    }
411}
412
413impl service::TreeState {
414    /// Deserializes and returns the Sapling note commitment tree field of the tree state.
415    pub fn sapling_tree(
416        &self,
417    ) -> io::Result<CommitmentTree<Node, { sapling::NOTE_COMMITMENT_TREE_DEPTH }>> {
418        if self.sapling_tree.is_empty() {
419            Ok(CommitmentTree::empty())
420        } else {
421            let sapling_tree_bytes = hex::decode(&self.sapling_tree).map_err(|e| {
422                io::Error::new(
423                    io::ErrorKind::InvalidData,
424                    format!("Hex decoding of Sapling tree bytes failed: {e:?}"),
425                )
426            })?;
427            read_commitment_tree::<Node, _, { sapling::NOTE_COMMITMENT_TREE_DEPTH }>(
428                &sapling_tree_bytes[..],
429            )
430        }
431    }
432
433    /// Deserializes and returns the Sapling note commitment tree field of the tree state.
434    #[cfg(feature = "orchard")]
435    pub fn orchard_tree(
436        &self,
437    ) -> io::Result<CommitmentTree<MerkleHashOrchard, { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 }>>
438    {
439        if self.orchard_tree.is_empty() {
440            Ok(CommitmentTree::empty())
441        } else {
442            let orchard_tree_bytes = hex::decode(&self.orchard_tree).map_err(|e| {
443                io::Error::new(
444                    io::ErrorKind::InvalidData,
445                    format!("Hex decoding of Orchard tree bytes failed: {e:?}"),
446                )
447            })?;
448            read_commitment_tree::<
449                MerkleHashOrchard,
450                _,
451                { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 },
452            >(&orchard_tree_bytes[..])
453        }
454    }
455
456    /// Deserializes and returns the Ironwood note commitment tree field of the tree state.
457    ///
458    /// The Ironwood tree is Orchard-shaped, but Ironwood is a distinct pool tracked separately
459    /// from Orchard. An empty field yields an empty tree, which is the correct treestate at the
460    /// Ironwood pool's activation.
461    #[cfg(feature = "orchard")]
462    pub fn ironwood_tree(
463        &self,
464    ) -> io::Result<CommitmentTree<MerkleHashOrchard, { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 }>>
465    {
466        if self.ironwood_tree.is_empty() {
467            Ok(CommitmentTree::empty())
468        } else {
469            let ironwood_tree_bytes = hex::decode(&self.ironwood_tree).map_err(|e| {
470                io::Error::new(
471                    io::ErrorKind::InvalidData,
472                    format!("Hex decoding of Ironwood tree bytes failed: {e:?}"),
473                )
474            })?;
475            read_commitment_tree::<
476                MerkleHashOrchard,
477                _,
478                { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 },
479            >(&ironwood_tree_bytes[..])
480        }
481    }
482
483    /// Parses this tree state into a [`ChainState`] for use with [`scan_cached_blocks`].
484    ///
485    /// [`scan_cached_blocks`]: crate::data_api::chain::scan_cached_blocks
486    pub fn to_chain_state(&self) -> io::Result<ChainState> {
487        let mut hash_bytes = hex::decode(&self.hash).map_err(|e| {
488            io::Error::new(
489                io::ErrorKind::InvalidData,
490                format!("Block hash is not valid hex: {e:?}"),
491            )
492        })?;
493        // Zcashd hex strings for block hashes are byte-reversed.
494        hash_bytes.reverse();
495
496        Ok(ChainState::new(
497            self.height
498                .try_into()
499                .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid block height"))?,
500            BlockHash::try_from_slice(&hash_bytes).ok_or_else(|| {
501                io::Error::new(io::ErrorKind::InvalidData, "Invalid block hash length.")
502            })?,
503            self.sapling_tree()?.to_frontier(),
504            #[cfg(feature = "orchard")]
505            self.orchard_tree()?.to_frontier(),
506            #[cfg(feature = "orchard")]
507            self.ironwood_tree()?.to_frontier(),
508        ))
509    }
510}
511
512/// Constant for the V1 proposal serialization version.
513pub const PROPOSAL_SER_V1: u32 = 1;
514
515/// Errors that can occur in the process of decoding a [`Proposal`] from its protobuf
516/// representation.
517#[derive(Debug, Clone)]
518#[non_exhaustive]
519pub enum ProposalDecodingError<DbError> {
520    /// The encoded proposal contained no steps.
521    NoSteps,
522    /// The ZIP 321 transaction request URI was invalid.
523    Zip321(Zip321Error),
524    /// A proposed input was null.
525    NullInput(usize),
526    /// A transaction identifier string did not decode to a valid transaction ID.
527    TxIdInvalid(TryFromSliceError),
528    /// An invalid value pool identifier was encountered.
529    ValuePoolNotSupported(i32),
530    /// A failure occurred trying to retrieve an unspent note or UTXO from the wallet database.
531    InputRetrieval(DbError),
532    /// The unspent note or UTXO corresponding to a proposal input was not found in the wallet
533    /// database.
534    InputNotFound(TxId, PoolType, u32),
535    /// The transaction balance, or a component thereof, failed to decode correctly.
536    BalanceInvalid,
537    /// Failed to decode a ZIP-302-compliant memo from the provided memo bytes.
538    MemoInvalid(memo::Error),
539    /// The serialization version returned by the protobuf was not recognized.
540    VersionInvalid(u32),
541    /// The fee rule specified by the proposal is not supported by the wallet.
542    FeeRuleNotSupported(proposal::FeeRule),
543    /// The proposal violated balance or structural constraints.
544    ProposalInvalid(ProposalError),
545    /// An inputs field for the given protocol was present, but contained no input note references.
546    EmptyShieldedInputs(ShieldedPool),
547    /// A memo field was provided for a transparent output.
548    TransparentMemo,
549    /// Change outputs to the specified pool are not supported.
550    InvalidChangeRecipient(PoolType),
551    /// Ephemeral outputs to the specified pool are not supported.
552    InvalidEphemeralRecipient(PoolType),
553    /// The encoded confirmations policy was not valid (for example, a zero confirmation count or
554    /// trusted confirmations exceeding untrusted).
555    ConfirmationsPolicyInvalid,
556    /// A payment was directed to the Orchard pool while Ironwood is active at the proposal's target
557    /// height. Once Ironwood is active, Orchard-receiver payments target the Ironwood pool and only
558    /// change may return to Orchard, so such a payment cannot appear in a well-formed proposal.
559    OrchardPaymentProhibited,
560    /// A proposal step produces a shielded bundle (it spends shielded notes, pays to a shielded
561    /// pool, or returns shielded change) but its encoded anchor height is the zero sentinel. Every
562    /// shielded-tree lookup the step performs — including the dummy spends that pad an output-only
563    /// bundle — must be bound to a real anchor, so this combination cannot appear in a well-formed
564    /// proposal.
565    MissingShieldedAnchor,
566    /// The proposal specified an explicit transaction version header that the wallet does not
567    /// recognize.
568    ProposedVersionInvalid(u32),
569}
570
571impl<E> From<Zip321Error> for ProposalDecodingError<E> {
572    fn from(value: Zip321Error) -> Self {
573        Self::Zip321(value)
574    }
575}
576
577impl<E: Display> Display for ProposalDecodingError<E> {
578    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
579        match self {
580            ProposalDecodingError::NoSteps => write!(f, "The proposal had no steps."),
581            ProposalDecodingError::Zip321(err) => write!(f, "Transaction request invalid: {err}"),
582            ProposalDecodingError::NullInput(i) => {
583                write!(f, "Proposed input was null at index {i}")
584            }
585            ProposalDecodingError::TxIdInvalid(err) => {
586                write!(f, "Invalid transaction id: {err:?}")
587            }
588            ProposalDecodingError::ValuePoolNotSupported(id) => {
589                write!(f, "Invalid value pool identifier: {id:?}")
590            }
591            ProposalDecodingError::InputRetrieval(err) => {
592                write!(f, "An error occurred retrieving a transaction input: {err}")
593            }
594            ProposalDecodingError::InputNotFound(txid, pool, idx) => {
595                write!(f, "No {pool} input found for txid {txid}, index {idx}")
596            }
597            ProposalDecodingError::BalanceInvalid => {
598                write!(f, "An error occurred decoding the proposal balance.")
599            }
600            ProposalDecodingError::MemoInvalid(err) => {
601                write!(f, "An error occurred decoding a proposed memo: {err}")
602            }
603            ProposalDecodingError::VersionInvalid(v) => {
604                write!(f, "Unrecognized proposal version {v}")
605            }
606            ProposalDecodingError::FeeRuleNotSupported(r) => {
607                write!(
608                    f,
609                    "Fee calculation using the {r:?} fee rule is not supported."
610                )
611            }
612            ProposalDecodingError::ProposalInvalid(err) => write!(f, "{err}"),
613            ProposalDecodingError::EmptyShieldedInputs(protocol) => write!(
614                f,
615                "An inputs field was present for {protocol:?}, but contained no note references."
616            ),
617            ProposalDecodingError::TransparentMemo => {
618                write!(f, "Transparent outputs cannot have memos.")
619            }
620            ProposalDecodingError::InvalidChangeRecipient(pool_type) => write!(
621                f,
622                "Change outputs to the {pool_type} pool are not supported."
623            ),
624            ProposalDecodingError::InvalidEphemeralRecipient(pool_type) => write!(
625                f,
626                "Ephemeral outputs to the {pool_type} pool are not supported."
627            ),
628            ProposalDecodingError::ConfirmationsPolicyInvalid => {
629                write!(f, "The encoded confirmations policy was not valid.")
630            }
631            ProposalDecodingError::OrchardPaymentProhibited => write!(
632                f,
633                "A payment may not be directed to the Orchard pool once Ironwood is active."
634            ),
635            ProposalDecodingError::MissingShieldedAnchor => write!(
636                f,
637                "A proposal step that produces a shielded bundle must specify an anchor height."
638            ),
639            ProposalDecodingError::ProposedVersionInvalid(header) => write!(
640                f,
641                "The proposal specified an unrecognized transaction version header {header:#x}."
642            ),
643        }
644    }
645}
646
647impl<E: std::error::Error + 'static> std::error::Error for ProposalDecodingError<E> {
648    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
649        match self {
650            ProposalDecodingError::Zip321(e) => Some(e),
651            ProposalDecodingError::InputRetrieval(e) => Some(e),
652            ProposalDecodingError::MemoInvalid(e) => Some(e),
653            _ => None,
654        }
655    }
656}
657
658fn pool_type<T>(pool_id: i32) -> Result<PoolType, ProposalDecodingError<T>> {
659    match proposal::ValuePool::try_from(pool_id) {
660        Ok(proposal::ValuePool::Transparent) => Ok(PoolType::TRANSPARENT),
661        Ok(proposal::ValuePool::Sapling) => Ok(PoolType::SAPLING),
662        Ok(proposal::ValuePool::Orchard) => Ok(PoolType::ORCHARD),
663        Ok(proposal::ValuePool::Ironwood) => Ok(PoolType::IRONWOOD),
664        _ => Err(ProposalDecodingError::ValuePoolNotSupported(pool_id)),
665    }
666}
667
668impl proposal::ReceivedOutput {
669    pub fn parse_txid(&self) -> Result<TxId, TryFromSliceError> {
670        Ok(TxId::from_bytes(self.txid[..].try_into()?))
671    }
672
673    pub fn pool_type<T>(&self) -> Result<PoolType, ProposalDecodingError<T>> {
674        pool_type(self.value_pool)
675    }
676}
677
678impl proposal::ChangeValue {
679    pub fn pool_type<T>(&self) -> Result<PoolType, ProposalDecodingError<T>> {
680        pool_type(self.value_pool)
681    }
682}
683
684impl From<PoolType> for proposal::ValuePool {
685    fn from(value: PoolType) -> Self {
686        match value {
687            PoolType::Transparent => proposal::ValuePool::Transparent,
688            PoolType::Shielded(p) => p.into(),
689        }
690    }
691}
692
693impl From<ShieldedPool> for proposal::ValuePool {
694    fn from(value: ShieldedPool) -> Self {
695        match value {
696            ShieldedPool::Sapling => proposal::ValuePool::Sapling,
697            ShieldedPool::Orchard => proposal::ValuePool::Orchard,
698            ShieldedPool::Ironwood => proposal::ValuePool::Ironwood,
699        }
700    }
701}
702
703impl proposal::Proposal {
704    /// Serializes a [`Proposal`] based upon a supported [`StandardFeeRule`] to its protobuf
705    /// representation.
706    pub fn from_standard_proposal<NoteRef>(value: &Proposal<StandardFeeRule, NoteRef>) -> Self {
707        let steps = value
708            .steps()
709            .iter()
710            .map(|step| {
711                let transaction_request = step.transaction_request().to_uri();
712
713                // A decoded legacy step that defers its anchor encodes as the zero sentinel.
714                let anchor_height = step.anchor_height().map_or(0, u32::from);
715
716                let inputs = step
717                    .transparent_inputs()
718                    .iter()
719                    .map(|utxo| proposal::ProposedInput {
720                        value: Some(proposed_input::Value::ReceivedOutput(ReceivedOutput {
721                            txid: utxo.outpoint().hash().to_vec(),
722                            value_pool: proposal::ValuePool::Transparent.into(),
723                            index: utxo.outpoint().n(),
724                            value: utxo.txout().value().into(),
725                        })),
726                    })
727                    .chain(step.shielded_inputs().iter().flat_map(|s_in| {
728                        s_in.notes().iter().map(|rec_note| proposal::ProposedInput {
729                            value: Some(proposed_input::Value::ReceivedOutput(ReceivedOutput {
730                                txid: rec_note.txid().as_ref().to_vec(),
731                                value_pool: proposal::ValuePool::from(rec_note.note().pool())
732                                    .into(),
733                                index: rec_note.output_index().into(),
734                                value: rec_note.note().value().into(),
735                            })),
736                        })
737                    }))
738                    .chain(step.prior_step_inputs().iter().map(|p_in| {
739                        match p_in.output_index() {
740                            StepOutputIndex::Payment(i) => proposal::ProposedInput {
741                                value: Some(proposed_input::Value::PriorStepOutput(
742                                    PriorStepOutput {
743                                        step_index: p_in
744                                            .step_index()
745                                            .try_into()
746                                            .expect("Step index fits into a u32"),
747                                        payment_index: i
748                                            .try_into()
749                                            .expect("Payment index fits into a u32"),
750                                    },
751                                )),
752                            },
753                            StepOutputIndex::Change(i) => proposal::ProposedInput {
754                                value: Some(proposed_input::Value::PriorStepChange(
755                                    PriorStepChange {
756                                        step_index: p_in
757                                            .step_index()
758                                            .try_into()
759                                            .expect("Step index fits into a u32"),
760                                        change_index: i
761                                            .try_into()
762                                            .expect("Payment index fits into a u32"),
763                                    },
764                                )),
765                            },
766                        }
767                    }))
768                    .collect();
769
770                let payment_output_pools = step
771                    .payment_pools()
772                    .iter()
773                    .map(|(idx, pool_type)| proposal::PaymentOutputPool {
774                        payment_index: u32::try_from(*idx).expect("Payment index fits into a u32"),
775                        value_pool: proposal::ValuePool::from(*pool_type).into(),
776                    })
777                    .collect();
778
779                let balance = Some(proposal::TransactionBalance {
780                    proposed_change: step
781                        .balance()
782                        .proposed_change()
783                        .iter()
784                        .map(|change| proposal::ChangeValue {
785                            value: change.value().into(),
786                            value_pool: proposal::ValuePool::from(change.output_pool()).into(),
787                            memo: change.memo().map(|memo_bytes| proposal::MemoBytes {
788                                value: memo_bytes.as_slice().to_vec(),
789                            }),
790                            is_ephemeral: change.is_ephemeral(),
791                        })
792                        .collect(),
793                    fee_required: step.balance().fee_required().into(),
794                    dummy_outputs: step.balance().dummy_outputs().map(|counts| {
795                        proposal::DummyOutputs {
796                            sapling: counts
797                                .sapling()
798                                .try_into()
799                                .expect("Sapling dummy-output count fits into u32"),
800                            #[cfg(feature = "orchard")]
801                            orchard: counts
802                                .orchard()
803                                .try_into()
804                                .expect("Orchard dummy-output count fits into u32"),
805                            #[cfg(not(feature = "orchard"))]
806                            orchard: 0,
807                            #[cfg(feature = "orchard")]
808                            ironwood: counts
809                                .ironwood()
810                                .try_into()
811                                .expect("Ironwood dummy-output count fits into u32"),
812                            #[cfg(not(feature = "orchard"))]
813                            ironwood: 0,
814                        }
815                    }),
816                });
817
818                proposal::ProposalStep {
819                    transaction_request,
820                    payment_output_pools,
821                    anchor_height,
822                    inputs,
823                    balance,
824                    is_shielding: step.is_shielding(),
825                }
826            })
827            .collect();
828
829        let confirmations_policy = value.confirmations_policy();
830        proposal::Proposal {
831            proto_version: PROPOSAL_SER_V1,
832            fee_rule: match value.fee_rule() {
833                StandardFeeRule::Zip317 => proposal::FeeRule::Zip317,
834            }
835            .into(),
836            min_target_height: value.min_target_height().into(),
837            steps,
838            confirmations_policy: Some(proposal::ConfirmationsPolicy {
839                trusted: confirmations_policy.trusted().into(),
840                untrusted: confirmations_policy.untrusted().into(),
841                #[cfg(feature = "transparent-inputs")]
842                allow_zero_conf_shielding: confirmations_policy.allow_zero_conf_shielding(),
843                #[cfg(not(feature = "transparent-inputs"))]
844                allow_zero_conf_shielding: true,
845            }),
846            proposed_version: value.proposed_version().map(|v| v.header()),
847        }
848    }
849
850    /// Attempts to parse a [`Proposal`] based upon a supported [`StandardFeeRule`] from its
851    /// protobuf representation.
852    pub fn try_into_standard_proposal<ParamsT, DbT, DbError>(
853        &self,
854        params: &ParamsT,
855        wallet_db: &DbT,
856    ) -> Result<Proposal<StandardFeeRule, DbT::NoteRef>, ProposalDecodingError<DbError>>
857    where
858        ParamsT: consensus::Parameters,
859        DbT: InputSource<Error = DbError>,
860    {
861        match self.proto_version {
862            PROPOSAL_SER_V1 => {
863                let fee_rule = match self.fee_rule() {
864                    proposal::FeeRule::Zip317 => StandardFeeRule::Zip317,
865                    other => {
866                        return Err(ProposalDecodingError::FeeRuleNotSupported(other));
867                    }
868                };
869
870                let target_height = TargetHeight::from(self.min_target_height);
871
872                // A proposal created with `lock_for_blocks` locks its own inputs, so
873                // input retrieval during decoding must not filter locked outputs;
874                // otherwise a locked proposal would fail to round-trip through its
875                // serialized form. Double-spend protection is enforced when the
876                // proposal's transactions are created, not here.
877                let lock_filter = LockFilter::Unfiltered;
878
879                // Steps are checked against the Orchard turnstile when Ironwood is
880                // active at the height for which the proposal was constructed.
881                #[cfg(feature = "orchard")]
882                let ironwood_active = params.is_nu_active(
883                    consensus::NetworkUpgrade::Nu6_3,
884                    BlockHeight::from(target_height),
885                );
886                #[cfg(not(feature = "orchard"))]
887                let _ = params;
888
889                let mut steps = Vec::with_capacity(self.steps.len());
890                for step in &self.steps {
891                    let transaction_request =
892                        TransactionRequest::from_uri(&step.transaction_request)?;
893
894                    let payment_pools = step
895                        .payment_output_pools
896                        .iter()
897                        .map(|pop| {
898                            Ok((
899                                usize::try_from(pop.payment_index)
900                                    .expect("Payment index fits into a usize"),
901                                pool_type(pop.value_pool)?,
902                            ))
903                        })
904                        .collect::<Result<BTreeMap<usize, PoolType>, ProposalDecodingError<DbError>>>()?;
905
906                    // With Ironwood active, no payment may be directed to the Orchard pool: an
907                    // Orchard-receiver payment targets the Ironwood pool, and only change may
908                    // return to Orchard. Reject such a payment from untrusted or legacy input here,
909                    // rather than letting it reach the `debug_assert!` in `Step::from_parts`.
910                    #[cfg(feature = "orchard")]
911                    if ironwood_active
912                        && payment_pools
913                            .values()
914                            .any(|pool| *pool == PoolType::ORCHARD)
915                    {
916                        return Err(ProposalDecodingError::OrchardPaymentProhibited);
917                    }
918
919                    #[allow(unused_mut)]
920                    let mut transparent_inputs = vec![];
921                    let mut received_notes = vec![];
922                    let mut prior_step_inputs = vec![];
923                    for (i, input) in step.inputs.iter().enumerate() {
924                        match input
925                            .value
926                            .as_ref()
927                            .ok_or(ProposalDecodingError::NullInput(i))?
928                        {
929                            ReceivedOutput(out) => {
930                                let txid = out
931                                    .parse_txid()
932                                    .map_err(ProposalDecodingError::TxIdInvalid)?;
933
934                                match out.pool_type()? {
935                                    PoolType::Transparent => {
936                                        #[cfg(not(feature = "transparent-inputs"))]
937                                        return Err(ProposalDecodingError::ValuePoolNotSupported(
938                                            out.value_pool,
939                                        ));
940
941                                        #[cfg(feature = "transparent-inputs")]
942                                        {
943                                            let outpoint = OutPoint::new(txid.into(), out.index);
944                                            transparent_inputs.push(
945                                                wallet_db
946                                                    .get_unspent_transparent_output(
947                                                        &outpoint,
948                                                        target_height,
949                                                    )
950                                                    .map_err(ProposalDecodingError::InputRetrieval)?
951                                                    .ok_or({
952                                                        ProposalDecodingError::InputNotFound(
953                                                            txid,
954                                                            PoolType::TRANSPARENT,
955                                                            out.index,
956                                                        )
957                                                    })?
958                                                    .redact_account_data(),
959                                            );
960                                        }
961                                    }
962                                    PoolType::Shielded(protocol) => received_notes.push(
963                                        wallet_db
964                                            .get_spendable_note(
965                                                &txid,
966                                                protocol,
967                                                out.index,
968                                                target_height,
969                                                lock_filter,
970                                            )
971                                            .map_err(ProposalDecodingError::InputRetrieval)
972                                            .and_then(|opt| {
973                                                opt.ok_or({
974                                                    ProposalDecodingError::InputNotFound(
975                                                        txid,
976                                                        PoolType::Shielded(protocol),
977                                                        out.index,
978                                                    )
979                                                })
980                                            })?,
981                                    ),
982                                }
983                            }
984                            PriorStepOutput(s_ref) => {
985                                prior_step_inputs.push(StepOutput::new(
986                                    s_ref
987                                        .step_index
988                                        .try_into()
989                                        .expect("Step index fits into a usize"),
990                                    StepOutputIndex::Payment(
991                                        s_ref
992                                            .payment_index
993                                            .try_into()
994                                            .expect("Payment index fits into a usize"),
995                                    ),
996                                ));
997                            }
998                            PriorStepChange(s_ref) => {
999                                prior_step_inputs.push(StepOutput::new(
1000                                    s_ref
1001                                        .step_index
1002                                        .try_into()
1003                                        .expect("Step index fits into a usize"),
1004                                    StepOutputIndex::Change(
1005                                        s_ref
1006                                            .change_index
1007                                            .try_into()
1008                                            .expect("Payment index fits into a usize"),
1009                                    ),
1010                                ));
1011                            }
1012                        }
1013                    }
1014
1015                    let shielded_inputs =
1016                        NonEmpty::from_vec(received_notes).map(ShieldedInputs::from_parts);
1017
1018                    let proto_balance = step
1019                        .balance
1020                        .as_ref()
1021                        .ok_or(ProposalDecodingError::BalanceInvalid)?;
1022                    let balance = TransactionBalance::new(
1023                        proto_balance
1024                            .proposed_change
1025                            .iter()
1026                            .map(|cv| -> Result<ChangeValue, ProposalDecodingError<_>> {
1027                                let value = Zatoshis::from_u64(cv.value)
1028                                    .map_err(|_| ProposalDecodingError::BalanceInvalid)?;
1029                                let memo = cv
1030                                    .memo
1031                                    .as_ref()
1032                                    .map(|bytes| {
1033                                        MemoBytes::from_bytes(&bytes.value)
1034                                            .map_err(ProposalDecodingError::MemoInvalid)
1035                                    })
1036                                    .transpose()?;
1037                                match (cv.pool_type()?, cv.is_ephemeral) {
1038                                    (PoolType::Shielded(ShieldedPool::Sapling), false) => {
1039                                        Ok(ChangeValue::sapling(value, memo))
1040                                    }
1041                                    #[cfg(feature = "orchard")]
1042                                    (PoolType::Shielded(ShieldedPool::Orchard), false) => {
1043                                        Ok(ChangeValue::orchard(value, memo))
1044                                    }
1045                                    #[cfg(feature = "orchard")]
1046                                    (PoolType::Shielded(ShieldedPool::Ironwood), false) => Ok(
1047                                        ChangeValue::shielded(ShieldedPool::Ironwood, value, memo),
1048                                    ),
1049                                    (PoolType::Transparent, _) if memo.is_some() => {
1050                                        Err(ProposalDecodingError::TransparentMemo)
1051                                    }
1052                                    #[cfg(feature = "transparent-inputs")]
1053                                    (PoolType::Transparent, true) => {
1054                                        Ok(ChangeValue::ephemeral_transparent(value))
1055                                    }
1056                                    #[cfg(feature = "transparent-inputs")]
1057                                    (PoolType::Transparent, false) => {
1058                                        Ok(ChangeValue::transparent(value))
1059                                    }
1060                                    // When all pool features are enabled, the explicit arms above
1061                                    // are exhaustive over the non-ephemeral cases; this fallback
1062                                    // remains reachable when some pool features are disabled.
1063                                    #[allow(unreachable_patterns)]
1064                                    (pool, false) => {
1065                                        Err(ProposalDecodingError::InvalidChangeRecipient(pool))
1066                                    }
1067                                    (pool, true) => {
1068                                        Err(ProposalDecodingError::InvalidEphemeralRecipient(pool))
1069                                    }
1070                                }
1071                            })
1072                            .collect::<Result<Vec<_>, _>>()?,
1073                        Zatoshis::from_u64(proto_balance.fee_required)
1074                            .map_err(|_| ProposalDecodingError::BalanceInvalid)?,
1075                    )
1076                    .map_err(|_| ProposalDecodingError::BalanceInvalid)?;
1077                    let balance = match proto_balance.dummy_outputs.as_ref() {
1078                        Some(counts) => {
1079                            #[cfg(feature = "orchard")]
1080                            let dummy_outputs = DummyOutputCounts::new(
1081                                counts.sapling as usize,
1082                                counts.orchard as usize,
1083                                counts.ironwood as usize,
1084                            );
1085                            #[cfg(not(feature = "orchard"))]
1086                            let dummy_outputs = DummyOutputCounts::new(counts.sapling as usize);
1087                            balance.with_dummy_outputs(dummy_outputs)
1088                        }
1089                        // Older proposals did not explicitly model their dummy outputs.
1090                        None => balance,
1091                    };
1092
1093                    // The `anchorHeight` field's zero value is the wire sentinel for a step that
1094                    // carries no anchor. Only a purely transparent step may lack one: any step that
1095                    // produces a shielded bundle binds every shielded-tree lookup — including the
1096                    // dummy spends that pad an output-only bundle — to a real anchor. Reject the
1097                    // invalid combination here at the parse boundary rather than letting it reach
1098                    // `Step::from_parts`.
1099                    let anchor_height = match step.anchor_height {
1100                        0 if produces_shielded_bundle(
1101                            shielded_inputs.is_some(),
1102                            &payment_pools,
1103                            &balance,
1104                        ) =>
1105                        {
1106                            return Err(ProposalDecodingError::MissingShieldedAnchor);
1107                        }
1108                        0 => None,
1109                        h => Some(BlockHeight::from_u32(h)),
1110                    };
1111
1112                    let step = Step::from_parts(
1113                        &steps,
1114                        transaction_request,
1115                        payment_pools,
1116                        transparent_inputs,
1117                        shielded_inputs,
1118                        anchor_height,
1119                        prior_step_inputs,
1120                        balance,
1121                        step.is_shielding,
1122                        #[cfg(feature = "orchard")]
1123                        ironwood_active,
1124                    )
1125                    .map_err(ProposalDecodingError::ProposalInvalid)?;
1126
1127                    steps.push(step);
1128                }
1129
1130                // Reconstruct the confirmations policy the proposal was built under. Proposals
1131                // serialized before this field existed omit it and are interpreted using the
1132                // default policy.
1133                let confirmations_policy = match &self.confirmations_policy {
1134                    Some(cp) => ConfirmationsPolicy::new(
1135                        NonZeroU32::new(cp.trusted)
1136                            .ok_or(ProposalDecodingError::ConfirmationsPolicyInvalid)?,
1137                        NonZeroU32::new(cp.untrusted)
1138                            .ok_or(ProposalDecodingError::ConfirmationsPolicyInvalid)?,
1139                        #[cfg(feature = "transparent-inputs")]
1140                        cp.allow_zero_conf_shielding,
1141                    )
1142                    .map_err(|_| ProposalDecodingError::ConfirmationsPolicyInvalid)?,
1143                    None => ConfirmationsPolicy::default(),
1144                };
1145
1146                // Recover the explicitly-requested transaction version, if any. Proposals
1147                // serialized before this field existed, or built without a version request, omit
1148                // it and fall back to the version implied by the target height.
1149                let proposed_version = self
1150                    .proposed_version
1151                    .map(|header| {
1152                        if header == TxVersion::V5.header() {
1153                            Ok(TxVersion::V5)
1154                        } else if header == TxVersion::V6.header() {
1155                            Ok(TxVersion::V6)
1156                        } else {
1157                            Err(ProposalDecodingError::ProposedVersionInvalid(header))
1158                        }
1159                    })
1160                    .transpose()?;
1161
1162                Proposal::multi_step(
1163                    fee_rule,
1164                    target_height,
1165                    confirmations_policy,
1166                    NonEmpty::from_vec(steps).ok_or(ProposalDecodingError::NoSteps)?,
1167                )
1168                .map(|proposal| proposal.with_proposed_version(proposed_version))
1169                .map_err(ProposalDecodingError::ProposalInvalid)
1170            }
1171            other => Err(ProposalDecodingError::VersionInvalid(other)),
1172        }
1173    }
1174}
1175
1176#[cfg(feature = "lightwalletd-tonic-transport")]
1177impl service::compact_tx_streamer_client::CompactTxStreamerClient<tonic::transport::Channel> {
1178    /// Attempt to create a new client by connecting to a given endpoint.
1179    pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
1180    where
1181        D: TryInto<tonic::transport::Endpoint>,
1182        D::Error: Into<tonic::codegen::StdError>,
1183    {
1184        let conn = tonic::transport::Endpoint::new(dst)?.connect().await?;
1185        Ok(Self::new(conn))
1186    }
1187}