zcash_client_backend/proposal.rs
1//! Types related to the construction and evaluation of transaction proposals.
2
3use std::{
4 collections::{BTreeMap, BTreeSet},
5 fmt::{self, Debug, Display},
6};
7
8use nonempty::NonEmpty;
9#[cfg(feature = "orchard")]
10use zcash_primitives::transaction::components::orchard::{
11 ACTION_SIZE as ORCHARD_ACTION_SIZE, BUNDLE_OVERHEAD as ORCHARD_BUNDLE_OVERHEAD,
12 SPEND_AUTH_SIG_SIZE as ORCHARD_AUTH_SIG_SIZE,
13};
14use zcash_primitives::transaction::{
15 TxId, TxVersion,
16 components::sapling::{
17 BUNDLE_OVERHEAD as SAPLING_BUNDLE_OVERHEAD, OUTPUT_DESCRIPTION_SIZE, SPEND_DESCRIPTION_SIZE,
18 },
19 fees::transparent as transparent_fees,
20 fees::zip317::{
21 MAX_TRANSPARENT_INPUT_SIZE, P2PKH_STANDARD_INPUT_SIZE, P2PKH_STANDARD_OUTPUT_SIZE,
22 TRANSPARENT_BUNDLE_OVERHEAD, TX_HEADER_SIZE,
23 },
24};
25use zcash_protocol::{
26 PoolType, ShieldedPool,
27 consensus::{BlockHeight, BranchId},
28 constants::MAX_BLOCK_BYTES,
29 value::Zatoshis,
30};
31use zip321::{TransactionRequest, Zip321Error};
32#[cfg(feature = "orchard")]
33use {
34 orchard::Proof, zcash_primitives::transaction::builder::BundlePadding,
35 zcash_protocol::zip318::PoolMigrationConstants,
36};
37
38use crate::{
39 data_api::wallet::{ConfirmationsPolicy, TargetHeight},
40 fees::TransactionBalance,
41 wallet::{Note, OutputRef, ReceivedNote, WalletTransparentOutput},
42};
43
44/// Errors that can occur in construction of a [`Step`].
45#[derive(Debug, Clone)]
46#[non_exhaustive]
47pub enum ProposalError {
48 /// The total output value of the transaction request is not a valid Zcash amount.
49 RequestTotalInvalid,
50 /// The total of transaction inputs overflows the valid range of Zcash values.
51 Overflow,
52 /// The input total and output total of the payment request are not equal to one another. The
53 /// sum of transaction outputs, change, and fees is required to be exactly equal to the value
54 /// of provided inputs.
55 BalanceError {
56 input_total: Zatoshis,
57 output_total: Zatoshis,
58 },
59 /// The `is_shielding` flag may only be set to `true` under the following conditions:
60 /// * The total of transparent inputs is nonzero
61 /// * There exist no Sapling inputs
62 /// * There provided transaction request is empty; i.e. the only output values specified
63 /// are change and fee amounts.
64 ShieldingInvalid,
65 /// No anchor information could be obtained for the specified block height.
66 AnchorNotFound(BlockHeight),
67 /// A proposal step produces a shielded bundle — it spends shielded notes, pays to a shielded
68 /// pool, or returns shielded change — but does not specify an anchor height against which its
69 /// shielded-tree lookups are performed. Only a purely transparent step may omit its anchor.
70 MissingShieldedAnchor,
71 /// A reference to the output of a prior step is invalid.
72 ReferenceError(StepOutput),
73 /// An attempted double-spend of a prior step output was detected.
74 StepDoubleSpend(StepOutput),
75 /// An attempted double-spend of an output belonging to the wallet was detected.
76 ChainDoubleSpend(OutputRef),
77 /// An input selected by the proposal could not be locked, because a concurrent
78 /// proposal or PCZT already holds a lock on it.
79 ///
80 /// This is a transient condition, not a defect in the proposal itself: the wrapped
81 /// output was spendable when it was selected, but another in-flight proposal locked
82 /// it first. Callers should treat this as "the account is busy" and retry proposal
83 /// creation; the retry will select around the locked output (or fail with an
84 /// insufficient-funds error if no other outputs are available).
85 InputsLocked(OutputRef),
86 /// There was a mismatch between the payments in the proposal's transaction request
87 /// and the payment pool selection values.
88 PaymentPoolsMismatch,
89 /// The proposal tried to spend a change output. Mark the `ChangeValue` as ephemeral if this is intended.
90 SpendsChange(StepOutput),
91 /// The proposal results in an invalid payment request according to ZIP-321.
92 Zip321(Zip321Error),
93 /// The ZIP 321 payment request at the wrapped index lacked payment amount information.
94 PaymentAmountMissing(usize),
95 /// A proposal step created an ephemeral output that was not spent in any later step.
96 #[cfg(feature = "transparent-inputs")]
97 EphemeralOutputLeftUnspent(StepOutput),
98 /// The proposal included a payment to a TEX address and a spend from a shielded input in the same step.
99 #[cfg(feature = "transparent-inputs")]
100 PaysTexFromShielded,
101 /// The change strategy provided to input selection failed to correctly generate an ephemeral
102 /// change output when needed for sending to a TEX address.
103 #[cfg(feature = "transparent-inputs")]
104 EphemeralOutputsInvalid,
105 /// The requested proposal would link activity on an ephemeral address to other wallet
106 /// activity.
107 #[cfg(feature = "transparent-inputs")]
108 EphemeralAddressLinkability,
109 /// A shielding proposal was constructed with a destination address that has no shielded
110 /// receiver. Shielding requires the destination to be able to receive shielded value.
111 #[cfg(feature = "transparent-inputs")]
112 ShieldingRequiresShieldedRecipient,
113 /// The transaction version requested is not compatible with the consensus branch for which the
114 /// transaction is intended.
115 IncompatibleTxVersion(BranchId),
116 /// After NU6.3 activation, a payment to an Orchard receiver must be delivered through the
117 /// Ironwood pool, which requires a version 6 transaction. The explicitly-requested transaction
118 /// version has no Ironwood bundle, so it cannot carry the payment. (The Orchard turnstile is a
119 /// consensus rule after NU6.3: no payment may add value to the Orchard pool, so such a payment
120 /// cannot be delivered as a plain Orchard output either.)
121 OrchardReceiverRequiresIronwood(TxVersion),
122 /// After Ironwood activation, a proposal step would create value in the Orchard pool.
123 /// The turnstile only permits value to leave the pool: a step may return change to it
124 /// only when strictly less value returns than the step's Orchard inputs remove.
125 /// (Payments may never be directed to the Orchard pool after Ironwood activation;
126 /// payment classification maintains that invariant, and step construction enforces it
127 /// via [`ProposalError::OrchardPoolPayment`].)
128 #[cfg(feature = "orchard")]
129 OrchardPoolValueCreation {
130 /// The total value of the Orchard notes spent by the step.
131 input_total: Zatoshis,
132 /// The total value of the Orchard-pool change outputs created by the step.
133 output_total: Zatoshis,
134 },
135 /// After Ironwood activation, the payment at the wrapped index of the step's
136 /// transaction request was directed to the Orchard pool. The turnstile only permits
137 /// value to leave the pool, so no payment may be directed to it; payment
138 /// classification routes Orchard-receiver payments to the Ironwood pool instead.
139 #[cfg(feature = "orchard")]
140 OrchardPoolPayment(usize),
141 /// The transaction the step describes would exceed the maximum size a Zcash block may
142 /// carry, and so cannot be included in any block. The serialized size of the step's
143 /// transaction was estimated from its inputs, outputs, and change, using conservative
144 /// per-element byte costs; the estimate exceeds [`MAX_BLOCK_BYTES`].
145 ///
146 /// `MAX_BLOCK_BYTES` is the *block* size limit, not a per-transaction limit: a
147 /// transaction that fits just under it still cannot be mined, because the block must
148 /// also carry its header and coinbase transaction. Passing the gate is therefore
149 /// necessary but not sufficient for mineability near the boundary; the builder is the
150 /// final authority.
151 ///
152 /// Recovery for a caller that receives this error: reduce the number of inputs by
153 /// lowering the payment amount or by consolidating small notes via send-to-self
154 /// transactions before retrying. Each consolidation round must be mined and its note
155 /// commitments anchored before the resulting notes are spendable, so recovery spans
156 /// multiple confirmations. For `propose_send_max` this error is a dead end — there is
157 /// no smaller amount to back off to — and requires a multi-transaction strategy.
158 ///
159 /// [`MAX_BLOCK_BYTES`]: zcash_protocol::constants::MAX_BLOCK_BYTES
160 TransactionTooLarge {
161 /// The conservative upper-bound estimate of the step's serialized byte size.
162 estimated_size: usize,
163 /// The maximum serialized byte size a Zcash block may carry (`MAX_BLOCK_BYTES`).
164 limit: usize,
165 /// The number of Sapling inputs the step spends, including prior-step inputs.
166 /// Carried so a wallet can explain the situation in terms of notes rather than
167 /// bytes; the different shielded pools have very different per-input costs.
168 sapling_input_count: usize,
169 /// The number of Orchard inputs the step spends, including prior-step inputs.
170 #[cfg(feature = "orchard")]
171 orchard_input_count: usize,
172 /// The number of Ironwood inputs the step spends, including prior-step inputs.
173 #[cfg(feature = "orchard")]
174 ironwood_input_count: usize,
175 },
176}
177
178impl Display for ProposalError {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 match self {
181 ProposalError::RequestTotalInvalid => write!(
182 f,
183 "The total requested output value is not a valid Zcash amount."
184 ),
185 ProposalError::Overflow => write!(
186 f,
187 "The total of transaction inputs overflows the valid range of Zcash values."
188 ),
189 ProposalError::BalanceError {
190 input_total,
191 output_total,
192 } => write!(
193 f,
194 "Balance error: the output total {} was not equal to the input total {}",
195 u64::from(*output_total),
196 u64::from(*input_total)
197 ),
198 ProposalError::ShieldingInvalid => write!(
199 f,
200 "The proposal violates the rules for a shielding transaction."
201 ),
202 ProposalError::AnchorNotFound(h) => {
203 write!(f, "Unable to compute anchor for block height {h:?}")
204 }
205 ProposalError::MissingShieldedAnchor => write!(
206 f,
207 "A proposal step that produces a shielded bundle must specify an anchor height."
208 ),
209 ProposalError::ReferenceError(r) => {
210 write!(f, "No prior step output found for reference {r:?}")
211 }
212 ProposalError::StepDoubleSpend(r) => write!(
213 f,
214 "The proposal uses the output of step {r:?} in more than one place."
215 ),
216 ProposalError::ChainDoubleSpend(output_ref) => write!(
217 f,
218 "The proposal attempts to spend the same output twice: {}, {}, {}",
219 output_ref.pool(),
220 output_ref.txid(),
221 output_ref.output_index()
222 ),
223 ProposalError::InputsLocked(output_ref) => write!(
224 f,
225 "A selected input is locked by a concurrent proposal (retry later): {}, {}, {}",
226 output_ref.pool(),
227 output_ref.txid(),
228 output_ref.output_index()
229 ),
230 ProposalError::PaymentPoolsMismatch => write!(
231 f,
232 "The chosen payment pools did not match the payments of the transaction request."
233 ),
234 ProposalError::SpendsChange(r) => write!(
235 f,
236 "The proposal attempts to spends the change output created at step {r:?}.",
237 ),
238 ProposalError::Zip321(r) => {
239 write!(f, "The proposal results in an invalid payment {r:?}.",)
240 }
241 ProposalError::PaymentAmountMissing(idx) => {
242 write!(
243 f,
244 "Payment amount not specified for requested payment at index {idx}."
245 )
246 }
247 #[cfg(feature = "transparent-inputs")]
248 ProposalError::EphemeralOutputLeftUnspent(r) => write!(
249 f,
250 "The proposal created an ephemeral output at step {r:?} that was not spent in any later step.",
251 ),
252 #[cfg(feature = "transparent-inputs")]
253 ProposalError::PaysTexFromShielded => write!(
254 f,
255 "The proposal included a payment to a TEX address and a spend from a shielded input in the same step.",
256 ),
257 #[cfg(feature = "transparent-inputs")]
258 ProposalError::EphemeralOutputsInvalid => write!(
259 f,
260 "The proposal generator failed to correctly generate an ephemeral change output when needed for sending to a TEX address."
261 ),
262 #[cfg(feature = "transparent-inputs")]
263 ProposalError::EphemeralAddressLinkability => write!(
264 f,
265 "The proposal requested spending funds in a way that would link activity on an ephemeral address to other wallet activity."
266 ),
267 #[cfg(feature = "transparent-inputs")]
268 ProposalError::ShieldingRequiresShieldedRecipient => write!(
269 f,
270 "A shielding proposal's destination must have a shielded receiver."
271 ),
272 #[cfg(feature = "orchard")]
273 ProposalError::OrchardPoolValueCreation {
274 input_total,
275 output_total,
276 } => write!(
277 f,
278 "After Ironwood activation, a step that spends {} zatoshis from the Orchard pool may not return {} zatoshis to it.",
279 u64::from(*input_total),
280 u64::from(*output_total),
281 ),
282 #[cfg(feature = "orchard")]
283 ProposalError::OrchardPoolPayment(index) => write!(
284 f,
285 "After Ironwood activation, no payment may be directed to the Orchard pool (payment index {index})."
286 ),
287 ProposalError::IncompatibleTxVersion(branch_id) => write!(
288 f,
289 "The requested transaction version is incompatible with consensus branch {branch_id:?}"
290 ),
291 ProposalError::OrchardReceiverRequiresIronwood(version) => write!(
292 f,
293 "After NU6.3 activation, a payment to an Orchard receiver requires a version 6 (Ironwood) transaction, but version {version:?} was requested."
294 ),
295 ProposalError::TransactionTooLarge {
296 estimated_size,
297 limit,
298 sapling_input_count,
299 #[cfg(feature = "orchard")]
300 orchard_input_count,
301 #[cfg(feature = "orchard")]
302 ironwood_input_count,
303 } => {
304 #[cfg(feature = "orchard")]
305 let _ = (orchard_input_count, ironwood_input_count);
306 write!(
307 f,
308 "The proposed transaction's estimated serialized size ({estimated_size} bytes) exceeds the maximum block size ({limit} bytes). It spends {sapling_input_count} Sapling inputs"
309 )?;
310 #[cfg(feature = "orchard")]
311 {
312 write!(
313 f,
314 ", {orchard_input_count} Orchard inputs, {ironwood_input_count} Ironwood inputs"
315 )?;
316 }
317 write!(
318 f,
319 "; reduce the payment amount or consolidate small notes before retrying."
320 )
321 }
322 }
323 }
324}
325
326impl std::error::Error for ProposalError {}
327
328/// The Sapling inputs to a proposed transaction.
329#[derive(Clone, PartialEq, Eq)]
330pub struct ShieldedInputs<NoteRef> {
331 notes: NonEmpty<ReceivedNote<NoteRef, Note>>,
332}
333
334impl<NoteRef> ShieldedInputs<NoteRef> {
335 /// Constructs a [`ShieldedInputs`] from its constituent parts.
336 pub fn from_parts(notes: NonEmpty<ReceivedNote<NoteRef, Note>>) -> Self {
337 Self { notes }
338 }
339
340 /// Returns the list of Sapling notes to be used as inputs to the proposed transaction.
341 pub fn notes(&self) -> &NonEmpty<ReceivedNote<NoteRef, Note>> {
342 &self.notes
343 }
344}
345
346/// A proposal for a series of transactions to be created.
347///
348/// Each step of the proposal represents a separate transaction to be created. At present, only
349/// transparent outputs of earlier steps may be spent in later steps; the ability to chain shielded
350/// transaction steps may be added in a future update.
351#[derive(Clone, PartialEq, Eq)]
352pub struct Proposal<FeeRuleT, NoteRef> {
353 fee_rule: FeeRuleT,
354 min_target_height: TargetHeight,
355 /// The confirmations policy under which the proposal was constructed. It is used to resolve
356 /// the anchor for a step that carries no explicit anchor height (a purely transparent step;
357 /// see [`Step::anchor_height`]).
358 confirmations_policy: ConfirmationsPolicy,
359 steps: NonEmpty<Step<NoteRef>>,
360 /// The transaction version explicitly requested when the proposal was constructed, if any.
361 /// When `None`, the transaction is built at the version implied by the target height (version 6
362 /// from NU6.3 onward).
363 proposed_version: Option<TxVersion>,
364}
365
366impl<FeeRuleT, NoteRef> Proposal<FeeRuleT, NoteRef> {
367 /// Constructs a validated multi-step [`Proposal`].
368 ///
369 /// This operation validates the proposal for agreement between outputs and inputs
370 /// in the case of multi-step proposals, and ensures that no double-spends are being
371 /// proposed.
372 ///
373 /// Parameters:
374 /// * `fee_rule`: The fee rule observed by the proposed transaction.
375 /// * `min_target_height`: The minimum block height at which the transaction may be created.
376 /// * `steps`: A vector of steps that make up the proposal.
377 pub fn multi_step(
378 fee_rule: FeeRuleT,
379 min_target_height: TargetHeight,
380 confirmations_policy: ConfirmationsPolicy,
381 steps: NonEmpty<Step<NoteRef>>,
382 ) -> Result<Self, ProposalError> {
383 let mut consumed_chain_inputs: BTreeSet<OutputRef> = BTreeSet::new();
384 let mut consumed_prior_inputs: BTreeSet<StepOutput> = BTreeSet::new();
385
386 for (i, step) in steps.iter().enumerate() {
387 for prior_ref in step.prior_step_inputs() {
388 // check that there are no forward references
389 if prior_ref.step_index() >= i {
390 return Err(ProposalError::ReferenceError(*prior_ref));
391 }
392 // check that the reference is valid
393 let prior_step = &steps[prior_ref.step_index()];
394 match prior_ref.output_index() {
395 StepOutputIndex::Payment(idx) => {
396 if prior_step.transaction_request().payments().len() <= idx {
397 return Err(ProposalError::ReferenceError(*prior_ref));
398 }
399 }
400 StepOutputIndex::Change(idx) => {
401 if prior_step.balance().proposed_change().len() <= idx {
402 return Err(ProposalError::ReferenceError(*prior_ref));
403 }
404 }
405 }
406 // check that there are no double-spends
407 if !consumed_prior_inputs.insert(*prior_ref) {
408 return Err(ProposalError::StepDoubleSpend(*prior_ref));
409 }
410 }
411
412 for t_out in step.transparent_inputs() {
413 let output_ref = OutputRef::new(
414 TxId::from_bytes(*t_out.outpoint().hash()),
415 PoolType::TRANSPARENT,
416 t_out.outpoint().n(),
417 );
418 if !consumed_chain_inputs.insert(output_ref) {
419 return Err(ProposalError::ChainDoubleSpend(output_ref));
420 }
421 }
422
423 for s_out in step.shielded_inputs().iter().flat_map(|i| i.notes().iter()) {
424 let output_ref = OutputRef::new(
425 *s_out.txid(),
426 PoolType::Shielded(s_out.note().pool()),
427 s_out.output_index().into(),
428 );
429 if !consumed_chain_inputs.insert(output_ref) {
430 return Err(ProposalError::ChainDoubleSpend(output_ref));
431 }
432 }
433 }
434
435 Ok(Self {
436 fee_rule,
437 min_target_height,
438 confirmations_policy,
439 proposed_version: None,
440 steps,
441 })
442 }
443
444 /// Constructs a validated [`Proposal`] having only a single step from its constituent parts.
445 ///
446 /// This operation validates the proposal for balance consistency and agreement between
447 /// the `is_shielding` flag and the structure of the proposal.
448 ///
449 /// Parameters:
450 /// * `transaction_request`: The ZIP 321 transaction request describing the payments to be
451 /// made.
452 /// * `payment_pools`: A map from payment index to pool type.
453 /// * `transparent_inputs`: The set of previous transparent outputs to be spent.
454 /// * `shielded_inputs`: The sets of previous shielded outputs to be spent.
455 /// * `anchor_height`: See [`Step::from_parts`].
456 /// * `balance`: The change outputs to be added the transaction and the fee to be paid.
457 /// * `fee_rule`: The fee rule observed by the proposed transaction.
458 /// * `min_target_height`: The minimum block height at which the transaction may be created.
459 /// * `is_shielding`: A flag that identifies whether this is a wallet-internal shielding
460 /// transaction.
461 /// * `ironwood_active`: See [`Step::from_parts`].
462 #[allow(clippy::too_many_arguments)]
463 pub fn single_step(
464 transaction_request: TransactionRequest,
465 payment_pools: BTreeMap<usize, PoolType>,
466 transparent_inputs: Vec<WalletTransparentOutput<()>>,
467 shielded_inputs: Option<ShieldedInputs<NoteRef>>,
468 anchor_height: BlockHeight,
469 balance: TransactionBalance,
470 fee_rule: FeeRuleT,
471 min_target_height: TargetHeight,
472 confirmations_policy: ConfirmationsPolicy,
473 is_shielding: bool,
474 #[cfg(feature = "orchard")] ironwood_active: bool,
475 ) -> Result<Self, ProposalError> {
476 Ok(Self {
477 fee_rule,
478 min_target_height,
479 confirmations_policy,
480 proposed_version: None,
481 steps: NonEmpty::singleton(Step::from_parts(
482 &[],
483 transaction_request,
484 payment_pools,
485 transparent_inputs,
486 shielded_inputs,
487 Some(anchor_height),
488 vec![],
489 balance,
490 is_shielding,
491 #[cfg(feature = "orchard")]
492 ironwood_active,
493 )?),
494 })
495 }
496
497 /// Returns the fee rule to be used by the transaction builder.
498 pub fn fee_rule(&self) -> &FeeRuleT {
499 &self.fee_rule
500 }
501
502 /// Returns the target height for which the proposal was prepared.
503 ///
504 /// The chain must contain at least this many blocks in order for the proposal to
505 /// be executed.
506 pub fn min_target_height(&self) -> TargetHeight {
507 self.min_target_height
508 }
509
510 /// Returns the confirmations policy under which the proposal was constructed. It is used to
511 /// resolve the anchor for a step that carries no explicit anchor height (a purely transparent
512 /// step; see [`Step::anchor_height`]).
513 pub fn confirmations_policy(&self) -> ConfirmationsPolicy {
514 self.confirmations_policy
515 }
516
517 /// Returns the transaction version explicitly requested when the proposal was constructed, if
518 /// any. When `None`, the transaction is built at the version implied by the target height
519 /// (version 6 from NU6.3 onward).
520 pub fn proposed_version(&self) -> Option<TxVersion> {
521 self.proposed_version
522 }
523
524 /// Returns this proposal with its requested transaction version set to the given value.
525 ///
526 /// This records the version passed to proposal construction so that it is carried through to
527 /// transaction building; it does not re-validate the proposal against the version.
528 pub fn with_proposed_version(mut self, proposed_version: Option<TxVersion>) -> Self {
529 self.proposed_version = proposed_version;
530 self
531 }
532
533 /// Returns the steps of the proposal. Each step corresponds to an independent transaction to
534 /// be generated as a result of this proposal.
535 pub fn steps(&self) -> &NonEmpty<Step<NoteRef>> {
536 &self.steps
537 }
538
539 /// Returns the total number of inputs across all steps of this proposal that belong to the
540 /// given pool.
541 ///
542 /// For a shielded pool this is the number of spent notes of that pool (Ironwood notes are
543 /// counted for [`PoolType::IRONWOOD`], not [`PoolType::ORCHARD`]); for
544 /// [`PoolType::Transparent`] it is the number of transparent inputs. See
545 /// [`Step::input_count_in_pool`].
546 pub fn input_count_in_pool(&self, pool_type: PoolType) -> usize {
547 self.steps
548 .iter()
549 .map(|step| step.input_count_in_pool(pool_type))
550 .sum()
551 }
552
553 /// Returns a conservative upper-bound estimate of the total serialized byte size
554 /// of all transactions this proposal describes, by summing the per-step estimates
555 /// (see [`Step::estimated_serialized_size`]). Each step is estimated independently,
556 /// so the total is the sum of the per-step serialized sizes, not the size of a
557 /// single transaction.
558 ///
559 /// The estimate is conservative (it never under-counts). See
560 /// [`Step::estimated_serialized_size`] for the per-element costs and the
561 /// mineability caveat.
562 pub fn estimated_serialized_size(&self) -> usize {
563 let steps = self.steps();
564 steps
565 .iter()
566 .map(|step| {
567 step.estimated_serialized_size(&|s_ref| {
568 Self::resolve_prior_output_pool(steps, s_ref)
569 })
570 })
571 .sum()
572 }
573
574 /// Checks whether every step's estimated serialized size fits within the maximum
575 /// a Zcash block may carry, returning [`ProposalError::TransactionTooLarge`] for
576 /// the first step that does not.
577 ///
578 /// This is called by the public `propose_transfer` / `propose_shielding` wrappers
579 /// and the `create_proposed_transactions` / `create_pczt_from_proposal` build
580 /// entry points, so it protects all input selectors (including third-party ones)
581 /// and all build paths. It is not called during proposal deserialization, so
582 /// previously-persisted proposals remain recoverable.
583 pub fn check_transaction_size(&self) -> Result<(), ProposalError> {
584 let steps = self.steps();
585 for step in steps.iter() {
586 let estimated_size = step
587 .estimated_serialized_size(&|s_ref| Self::resolve_prior_output_pool(steps, s_ref));
588 if estimated_size > MAX_BLOCK_BYTES {
589 // Count per-pool shielded inputs (both the step's own and prior-step)
590 // so the counts match what the estimate charged.
591 let sapling_input_count = step.input_count_in_pool(PoolType::SAPLING)
592 + step
593 .prior_step_inputs()
594 .iter()
595 .filter(|s_ref| {
596 Self::resolve_prior_output_pool(steps, s_ref) == PoolType::SAPLING
597 })
598 .count();
599 #[cfg(feature = "orchard")]
600 let orchard_input_count = step.input_count_in_pool(PoolType::ORCHARD)
601 + step
602 .prior_step_inputs()
603 .iter()
604 .filter(|s_ref| {
605 Self::resolve_prior_output_pool(steps, s_ref) == PoolType::ORCHARD
606 })
607 .count();
608 #[cfg(feature = "orchard")]
609 let ironwood_input_count = step.input_count_in_pool(PoolType::IRONWOOD)
610 + step
611 .prior_step_inputs()
612 .iter()
613 .filter(|s_ref| {
614 Self::resolve_prior_output_pool(steps, s_ref) == PoolType::IRONWOOD
615 })
616 .count();
617 return Err(ProposalError::TransactionTooLarge {
618 estimated_size,
619 limit: MAX_BLOCK_BYTES,
620 sapling_input_count,
621 #[cfg(feature = "orchard")]
622 orchard_input_count,
623 #[cfg(feature = "orchard")]
624 ironwood_input_count,
625 });
626 }
627 }
628 Ok(())
629 }
630
631 /// Resolves the pool of a prior-step output referenced by `s_ref`, by looking up
632 /// the referenced step in the proposal's step list. Shared by
633 /// [`estimated_serialized_size`](Self::estimated_serialized_size) and
634 /// [`check_transaction_size`](Self::check_transaction_size) to avoid duplicating the
635 /// `StepOutput`-to-`PoolType` resolution logic.
636 fn resolve_prior_output_pool(steps: &NonEmpty<Step<NoteRef>>, s_ref: &StepOutput) -> PoolType {
637 let prior = &steps[s_ref.step_index()];
638 match s_ref.output_index() {
639 StepOutputIndex::Payment(i) => *prior
640 .payment_pools()
641 .get(&i)
642 .expect("payment pool exists for referenced payment"),
643 StepOutputIndex::Change(i) => prior.balance().proposed_change()[i].output_pool(),
644 }
645 }
646}
647
648impl<FeeRuleT: Debug, NoteRef> Debug for Proposal<FeeRuleT, NoteRef> {
649 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
650 f.debug_struct("Proposal")
651 .field("fee_rule", &self.fee_rule)
652 .field("min_target_height", &self.min_target_height)
653 .field("proposed_version", &self.proposed_version)
654 .field("steps", &self.steps)
655 .finish()
656 }
657}
658
659/// A reference to either a payment or change output within a step.
660#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
661pub enum StepOutputIndex {
662 Payment(usize),
663 Change(usize),
664}
665
666/// A reference to the output of a step in a proposal.
667#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
668pub struct StepOutput {
669 step_index: usize,
670 output_index: StepOutputIndex,
671}
672
673impl StepOutput {
674 /// Constructs a new [`StepOutput`] from its constituent parts.
675 pub fn new(step_index: usize, output_index: StepOutputIndex) -> Self {
676 Self {
677 step_index,
678 output_index,
679 }
680 }
681
682 /// Returns the step index to which this reference refers.
683 pub fn step_index(&self) -> usize {
684 self.step_index
685 }
686
687 /// Returns the identifier for the payment or change output within
688 /// the referenced step.
689 pub fn output_index(&self) -> StepOutputIndex {
690 self.output_index
691 }
692}
693
694/// The inputs to be consumed and outputs to be produced in a proposed transaction.
695#[derive(Clone, PartialEq, Eq)]
696pub struct Step<NoteRef> {
697 transaction_request: TransactionRequest,
698 payment_pools: BTreeMap<usize, PoolType>,
699 transparent_inputs: Vec<WalletTransparentOutput<()>>,
700 shielded_inputs: Option<ShieldedInputs<NoteRef>>,
701 /// The anchor height that binds every shielded-tree lookup performed while building this
702 /// step's transaction — both shielded-input witnesses and shielded-output anchors.
703 ///
704 /// This is `Some` for any step that produces a shielded bundle — one that spends shielded
705 /// notes, pays to a shielded pool, or returns shielded change. The anchor is selected from the
706 /// wallet's checkpoints at proposal construction time and applied to every shielded-tree
707 /// lookup, so a transaction with only routed shielded outputs (for example an Orchard-receiver
708 /// payment routed into the Ironwood bundle post-NU6.3) remains indistinguishable from one that
709 /// spends real shielded notes. Only a purely transparent step may carry `None`.
710 anchor_height: Option<BlockHeight>,
711 prior_step_inputs: Vec<StepOutput>,
712 balance: TransactionBalance,
713 is_shielding: bool,
714}
715
716/// Returns whether a step produces any shielded bundle — it spends shielded notes, pays to a
717/// shielded pool, or returns change to a shielded pool. Such a step performs shielded-tree lookups
718/// (including the dummy spends that pad an output-only bundle so it is indistinguishable from one
719/// that spends real notes), so it must bind a concrete anchor against which those lookups are made.
720pub(crate) fn produces_shielded_bundle(
721 has_shielded_inputs: bool,
722 payment_pools: &BTreeMap<usize, PoolType>,
723 balance: &TransactionBalance,
724) -> bool {
725 has_shielded_inputs
726 || payment_pools
727 .values()
728 .any(|pool| matches!(pool, PoolType::Shielded(_)))
729 || balance
730 .proposed_change()
731 .iter()
732 .any(|change| matches!(change.output_pool(), PoolType::Shielded(_)))
733}
734
735impl<NoteRef> Step<NoteRef> {
736 /// Constructs a validated [`Step`] from its constituent parts.
737 ///
738 /// This operation validates the proposal for balance consistency and agreement between
739 /// the `is_shielding` flag and the structure of the proposal.
740 ///
741 /// Parameters:
742 /// * `transaction_request`: The ZIP 321 transaction request describing the payments
743 /// to be made.
744 /// * `payment_pools`: A map from payment index to pool type. The set of payment indices
745 /// provided here must exactly match the set of payment indices in the [`TransactionRequest`],
746 /// and the selected pool for an index must correspond to a valid receiver of the
747 /// address at that index (or the address itself in the case of bare transparent or Sapling
748 /// addresses).
749 /// * `transparent_inputs`: The set of previous transparent outputs to be spent.
750 /// * `shielded_inputs`: The sets of previous shielded outputs to be spent.
751 /// * `anchor_height`: The anchor height that binds every shielded-tree lookup performed
752 /// while building this step's transaction — both shielded-input witnesses and
753 /// shielded-output anchors. A step that produces a shielded bundle (spends shielded notes,
754 /// pays to a shielded pool, or returns shielded change) must provide `Some`; only a purely
755 /// transparent step may pass `None`.
756 /// * `balance`: The change outputs to be added the transaction and the fee to be paid.
757 /// * `is_shielding`: A flag that identifies whether this is a wallet-internal shielding
758 /// transaction.
759 /// * `ironwood_active`: Whether the Ironwood pool is active at the target height for
760 /// which this step is proposed. When active, the step is checked against the
761 /// Orchard turnstile: no payment may be directed to the Orchard pool, and change
762 /// may be returned to it only when strictly less value returns than the step's
763 /// Orchard inputs remove.
764 #[allow(clippy::too_many_arguments)]
765 pub fn from_parts(
766 prior_steps: &[Step<NoteRef>],
767 transaction_request: TransactionRequest,
768 payment_pools: BTreeMap<usize, PoolType>,
769 transparent_inputs: Vec<WalletTransparentOutput<()>>,
770 shielded_inputs: Option<ShieldedInputs<NoteRef>>,
771 anchor_height: Option<BlockHeight>,
772 prior_step_inputs: Vec<StepOutput>,
773 balance: TransactionBalance,
774 is_shielding: bool,
775 #[cfg(feature = "orchard")] ironwood_active: bool,
776 ) -> Result<Self, ProposalError> {
777 // Verify that the set of payment pools matches exactly a set of valid payment recipients
778 if transaction_request.payments().len() != payment_pools.len() {
779 return Err(ProposalError::PaymentPoolsMismatch);
780 }
781 for (idx, pool) in &payment_pools {
782 if let Some(payment) = transaction_request.payments().get(idx) {
783 // Ironwood notes are Orchard-shaped and delivered to the recipient's Orchard
784 // receiver, so an Ironwood-pool payment is valid whenever the recipient can
785 // receive Orchard.
786 let deliverable = payment.recipient_address().can_receive_as(*pool)
787 || (*pool == PoolType::IRONWOOD
788 && payment
789 .recipient_address()
790 .can_receive_as(PoolType::ORCHARD));
791 if !deliverable {
792 return Err(ProposalError::PaymentPoolsMismatch);
793 }
794 if payment.amount().is_none() {
795 return Err(ProposalError::PaymentAmountMissing(*idx));
796 }
797 } else {
798 return Err(ProposalError::PaymentPoolsMismatch);
799 }
800 }
801
802 let transparent_input_total = transparent_inputs
803 .iter()
804 .map(|out| out.txout().value())
805 .try_fold(Zatoshis::ZERO, |acc, a| {
806 (acc + a).ok_or(ProposalError::Overflow)
807 })?;
808
809 let shielded_input_total = shielded_inputs
810 .iter()
811 .flat_map(|s_in| s_in.notes().iter())
812 .map(|out| out.note().value())
813 .try_fold(Zatoshis::ZERO, |acc, a| acc + a)
814 .ok_or(ProposalError::Overflow)?;
815
816 let prior_step_input_total = prior_step_inputs
817 .iter()
818 .map(|s_ref| {
819 let step = prior_steps
820 .get(s_ref.step_index)
821 .ok_or(ProposalError::ReferenceError(*s_ref))?;
822 Ok(match s_ref.output_index {
823 StepOutputIndex::Payment(i) => step
824 .transaction_request
825 .payments()
826 .get(&i)
827 .ok_or(ProposalError::ReferenceError(*s_ref))?
828 .amount()
829 .ok_or(ProposalError::PaymentAmountMissing(i))?,
830 StepOutputIndex::Change(i) => step
831 .balance
832 .proposed_change()
833 .get(i)
834 .ok_or(ProposalError::ReferenceError(*s_ref))?
835 .value(),
836 })
837 })
838 .collect::<Result<Vec<_>, _>>()?
839 .into_iter()
840 .try_fold(Zatoshis::ZERO, |acc, a| acc + a)
841 .ok_or(ProposalError::Overflow)?;
842
843 let input_total = (transparent_input_total + shielded_input_total + prior_step_input_total)
844 .ok_or(ProposalError::Overflow)?;
845
846 let request_total = transaction_request
847 .total()
848 .map_err(|_| ProposalError::RequestTotalInvalid)?
849 .expect("all payments previously checked to have amount values");
850 let output_total = (request_total + balance.total()).ok_or(ProposalError::Overflow)?;
851
852 if is_shielding
853 && (transparent_input_total == Zatoshis::ZERO
854 || shielded_input_total > Zatoshis::ZERO
855 || request_total > Zatoshis::ZERO)
856 {
857 return Err(ProposalError::ShieldingInvalid);
858 }
859
860 // After Ironwood activation, the Orchard turnstile only permits value to leave
861 // the pool: payments may not be directed to the Orchard pool, and change may be
862 // returned to it only when strictly less value returns than the step's Orchard
863 // inputs remove.
864 #[cfg(feature = "orchard")]
865 if ironwood_active {
866 // With Ironwood active, payment classification routes every Orchard-receiver
867 // payment to the Ironwood pool before a step is constructed, so a payment
868 // directed to the Orchard pool here can only arise through direct misuse of
869 // this public constructor; reject it rather than construct a step whose
870 // transaction could never be mined. The only Orchard-pool outputs a step may
871 // create are change, which is validated below. The untrusted decode path
872 // rejects such a payment pool before calling `from_parts` (see
873 // `try_into_standard_proposal`).
874 if let Some((index, _)) = payment_pools
875 .iter()
876 .find(|(_, pool)| **pool == PoolType::ORCHARD)
877 {
878 return Err(ProposalError::OrchardPoolPayment(*index));
879 }
880
881 let orchard_input_total = shielded_inputs
882 .iter()
883 .flat_map(|s_in| s_in.notes().iter())
884 .filter(|n| n.note().pool() == ShieldedPool::Orchard)
885 .map(|n| n.note().value())
886 .try_fold(Zatoshis::ZERO, |acc, a| acc + a)
887 .ok_or(ProposalError::Overflow)?;
888
889 let orchard_change_total = balance
890 .proposed_change()
891 .iter()
892 .filter(|c| c.output_pool() == PoolType::ORCHARD)
893 .map(|c| c.value())
894 .try_fold(Zatoshis::ZERO, |acc, a| acc + a)
895 .ok_or(ProposalError::Overflow)?;
896
897 if orchard_change_total.is_positive() && orchard_change_total >= orchard_input_total {
898 return Err(ProposalError::OrchardPoolValueCreation {
899 input_total: orchard_input_total,
900 output_total: orchard_change_total,
901 });
902 }
903 }
904
905 // A step that produces any shielded bundle must bind a concrete anchor: every shielded-tree
906 // lookup it performs — including the dummy spends that pad an output-only bundle so it is
907 // indistinguishable from one that spends real notes — is made against that anchor. Only a
908 // purely transparent step may omit it. The untrusted decode path rejects this same
909 // combination at the parse boundary (see `try_into_standard_proposal`).
910 if anchor_height.is_none()
911 && produces_shielded_bundle(shielded_inputs.is_some(), &payment_pools, &balance)
912 {
913 return Err(ProposalError::MissingShieldedAnchor);
914 }
915
916 if input_total == output_total {
917 Ok(Self {
918 transaction_request,
919 payment_pools,
920 transparent_inputs,
921 shielded_inputs,
922 anchor_height,
923 prior_step_inputs,
924 balance,
925 is_shielding,
926 })
927 } else {
928 Err(ProposalError::BalanceError {
929 input_total,
930 output_total,
931 })
932 }
933 }
934
935 /// Returns the transaction request that describes the payments to be made.
936 pub fn transaction_request(&self) -> &TransactionRequest {
937 &self.transaction_request
938 }
939 /// Returns the map from payment index to the pool that has been selected
940 /// for the output that will fulfill that payment.
941 pub fn payment_pools(&self) -> &BTreeMap<usize, PoolType> {
942 &self.payment_pools
943 }
944 /// Returns the transparent inputs that have been selected to fund the transaction.
945 pub fn transparent_inputs(&self) -> &[WalletTransparentOutput<()>] {
946 &self.transparent_inputs
947 }
948 /// Returns the shielded inputs that have been selected to fund the transaction.
949 pub fn shielded_inputs(&self) -> Option<&ShieldedInputs<NoteRef>> {
950 self.shielded_inputs.as_ref()
951 }
952 /// Returns the anchor height that binds every shielded-tree lookup performed while building
953 /// this step's transaction, or `None` for a purely transparent step that performs no such
954 /// lookup.
955 pub fn anchor_height(&self) -> Option<BlockHeight> {
956 self.anchor_height
957 }
958 /// Returns the inputs that should be obtained from the outputs of the transaction
959 /// created to satisfy a previous step of the proposal.
960 pub fn prior_step_inputs(&self) -> &[StepOutput] {
961 self.prior_step_inputs.as_ref()
962 }
963 /// Returns the change outputs to be added to the transaction and the fee to be paid.
964 pub fn balance(&self) -> &TransactionBalance {
965 &self.balance
966 }
967 /// Returns a flag indicating whether or not the proposed transaction
968 /// is exclusively wallet-internal (if it does not involve any external
969 /// recipients).
970 pub fn is_shielding(&self) -> bool {
971 self.is_shielding
972 }
973
974 /// Returns whether or not this proposal requires interaction with the specified pool.
975 pub fn involves(&self, pool_type: PoolType) -> bool {
976 self.input_in_pool(pool_type)
977 || self.output_in_pool(pool_type)
978 || self.change_in_pool(pool_type)
979 }
980
981 /// Returns whether or not this step spends any inputs from the given pool.
982 ///
983 /// For a shielded pool this is true when a note of that protocol is spent; for
984 /// [`PoolType::Transparent`] it is true when the step is a shielding step or has any
985 /// transparent inputs.
986 pub fn input_in_pool(&self, pool_type: PoolType) -> bool {
987 match pool_type {
988 PoolType::Transparent => self.is_shielding() || !self.transparent_inputs().is_empty(),
989 PoolType::SAPLING => self.shielded_inputs().iter().any(|s_in| {
990 s_in.notes()
991 .iter()
992 .any(|note| matches!(note.note().pool(), ShieldedPool::Sapling))
993 }),
994 PoolType::ORCHARD => self.shielded_inputs().iter().any(|s_in| {
995 s_in.notes()
996 .iter()
997 .any(|note| matches!(note.note().pool(), ShieldedPool::Orchard))
998 }),
999 PoolType::IRONWOOD => self.shielded_inputs().iter().any(|s_in| {
1000 s_in.notes()
1001 .iter()
1002 .any(|note| matches!(note.note().pool(), ShieldedPool::Ironwood))
1003 }),
1004 }
1005 }
1006
1007 /// Returns whether or not this step directs any payment output to the given pool.
1008 ///
1009 /// This does not consider change outputs; use [`Step::change_in_pool`] for those.
1010 pub fn output_in_pool(&self, pool_type: PoolType) -> bool {
1011 self.payment_pools().values().any(|pool| *pool == pool_type)
1012 }
1013
1014 /// Returns whether or not this step directs any change output to the given pool.
1015 pub fn change_in_pool(&self, pool_type: PoolType) -> bool {
1016 self.balance()
1017 .proposed_change()
1018 .iter()
1019 .any(|c| c.output_pool() == pool_type)
1020 }
1021
1022 /// Returns the number of inputs to this step that belong to the given pool.
1023 ///
1024 /// For a shielded pool this is the number of spent notes of that protocol; for
1025 /// [`PoolType::Transparent`] it is the number of transparent inputs.
1026 pub fn input_count_in_pool(&self, pool_type: PoolType) -> usize {
1027 match pool_type {
1028 PoolType::Transparent => self.transparent_inputs().len(),
1029 PoolType::SAPLING => self
1030 .shielded_inputs()
1031 .iter()
1032 .flat_map(|s_in| s_in.notes())
1033 .filter(|note| note.note().pool() == ShieldedPool::Sapling)
1034 .count(),
1035 PoolType::ORCHARD => self
1036 .shielded_inputs()
1037 .iter()
1038 .flat_map(|s_in| s_in.notes())
1039 .filter(|note| note.note().pool() == ShieldedPool::Orchard)
1040 .count(),
1041 PoolType::IRONWOOD => self
1042 .shielded_inputs()
1043 .iter()
1044 .flat_map(|s_in| s_in.notes())
1045 .filter(|note| note.note().pool() == ShieldedPool::Ironwood)
1046 .count(),
1047 }
1048 }
1049
1050 /// Returns the number of payment outputs of this step that are directed to the given pool.
1051 ///
1052 /// This does not include change outputs; use [`Step::change_count_in_pool`] for those.
1053 pub fn output_count_in_pool(&self, pool_type: PoolType) -> usize {
1054 self.payment_pools()
1055 .values()
1056 .filter(|pool| **pool == pool_type)
1057 .count()
1058 }
1059
1060 /// Returns the number of change outputs of this step that are directed to the given pool.
1061 pub fn change_count_in_pool(&self, pool_type: PoolType) -> usize {
1062 self.balance()
1063 .proposed_change()
1064 .iter()
1065 .filter(|c| c.output_pool() == pool_type)
1066 .count()
1067 }
1068
1069 /// The value of this step's sole Ironwood PAYMENT output, or `None` when the step has any
1070 /// number of Ironwood payment outputs other than exactly one, or when that output's value is
1071 /// not determined (a ZIP 321 payment may carry no amount).
1072 ///
1073 /// Change is deliberately not considered. The fee model must reach the same verdict as
1074 /// [`Step::is_canonical_crossing`] from the same data, and at the point it decides an Ironwood
1075 /// change value is precisely the unknown it is solving for. That costs nothing: a step with
1076 /// Ironwood change alongside an Ironwood payment has two real Ironwood outputs, so its bundle
1077 /// already meets the default action floor and the padding makes no difference.
1078 #[cfg(feature = "orchard")]
1079 fn sole_ironwood_payment_value(&self) -> Option<Zatoshis> {
1080 let mut payments = self
1081 .payment_pools()
1082 .iter()
1083 .filter(|(_, pool)| **pool == PoolType::IRONWOOD)
1084 .map(|(index, _)| {
1085 self.transaction_request()
1086 .payments()
1087 .get(index)
1088 .and_then(|payment| payment.amount())
1089 });
1090
1091 match (payments.next(), payments.next()) {
1092 (Some(value), None) => value,
1093 _ => None,
1094 }
1095 }
1096
1097 /// Returns whether this step is a *canonical crossing*: a step whose whole shape is
1098 /// indistinguishable from a [ZIP 318] migration transfer, and which is therefore built with a
1099 /// single unpadded Ironwood action so that it joins that anonymity set. See
1100 /// [`Step::ironwood_bundle_padding`].
1101 ///
1102 /// A migration transfer spends one Orchard note, pads its Orchard bundle to the default
1103 /// two-action floor (the spend plus its change, or a dummy when the note's value exactly
1104 /// covers the crossing and its fee), outputs one canonical denomination into the Ironwood
1105 /// pool, and is proved against a boundary of the anchor bucket grid. Every one of those is
1106 /// required here:
1107 ///
1108 /// - exactly one Orchard input and at most one Orchard change output, so that the Orchard
1109 /// bundle is exactly two actions;
1110 /// - no change in any other pool;
1111 /// - no Ironwood spends, a step funded from Ironwood crossing nothing;
1112 /// - no Ironwood change, leaving a single Ironwood output;
1113 /// - that output's value is a canonical denomination under `params`;
1114 /// - the step's anchor lies on `params`' anchor bucket grid;
1115 /// - the fee equals `canonical_fee`, which the caller obtains from
1116 /// [`fees::canonical_crossing_fee`] for the consensus parameters and target height the
1117 /// transaction will actually be built against.
1118 ///
1119 /// [`fees::canonical_crossing_fee`]: crate::fees::canonical_crossing_fee
1120 ///
1121 /// Dropping the Ironwood padding without the rest would not buy anonymity but destroy it: an
1122 /// unpadded Ironwood bundle beside an Orchard bundle of the wrong size, or against a
1123 /// chain-tip anchor, is a shape nothing else on the network emits — a fingerprint rather than
1124 /// a disguise.
1125 ///
1126 /// [ZIP 318]: https://zips.z.cash/zip-0318
1127 #[cfg(feature = "orchard")]
1128 pub fn is_canonical_crossing<P: PoolMigrationConstants>(
1129 &self,
1130 params: &P,
1131 canonical_fee: Zatoshis,
1132 ) -> bool {
1133 self.input_count_in_pool(PoolType::ORCHARD) == 1
1134 && self.input_count_in_pool(PoolType::IRONWOOD) == 0
1135 && self.change_count_in_pool(PoolType::IRONWOOD) == 0
1136 // At most ONE Orchard change output. The Orchard bundle must be exactly two actions,
1137 // and from NU6.3 a spend and an output no longer share one, so its count is
1138 // `spends + outputs`: a second change output makes three. A multi-output change
1139 // strategy produces exactly that from a single large note.
1140 && self.change_count_in_pool(PoolType::ORCHARD) <= 1
1141 // Change anywhere else adds a bundle no migration transfer carries.
1142 && self.change_count_in_pool(PoolType::SAPLING) == 0
1143 && self.change_count_in_pool(PoolType::TRANSPARENT) == 0
1144 && self
1145 .sole_ironwood_payment_value()
1146 .is_some_and(|value| params.is_canonical_denomination(value))
1147 && self
1148 .anchor_height()
1149 .is_some_and(|anchor| params.anchor_grid().is_boundary(anchor))
1150 // ZIP 318 forbids a non-standard fee, which would partition the anonymity set as
1151 // surely as a non-standard shape. A `ChangeStrategy` may be built on any fee rule,
1152 // including a fixed non-standard one, so a structurally perfect crossing can still
1153 // carry a distinguishing fee.
1154 //
1155 // This condition is deliberately absent from `ironwood_bundle_padding`: the fee model
1156 // decides that padding WHILE computing the fee, so it cannot test the result against a
1157 // target. Keeping the fee here makes this predicate a gate on whether to keep a
1158 // proposal at all, rather than an input the builder must reproduce.
1159 && self.balance().fee_required() == canonical_fee
1160 }
1161
1162 /// The transactional bundle padding the transaction builder must use for this step's Ironwood
1163 /// bundle: the padding the [`ChangeStrategy`] recorded when it computed the fee.
1164 ///
1165 /// This is READ, not re-derived. The builder must produce exactly the action count the fee was
1166 /// charged against, and a second derivation could disagree with the first — which is precisely
1167 /// what happens for a condition only one of them can evaluate, such as whether the resulting
1168 /// fee is itself canonical.
1169 ///
1170 /// [`ChangeStrategy`]: crate::fees::ChangeStrategy
1171 #[cfg(feature = "orchard")]
1172 pub fn ironwood_bundle_padding(&self) -> BundlePadding {
1173 let Some(dummy_outputs) = self.balance().dummy_outputs() else {
1174 return BundlePadding::DEFAULT;
1175 };
1176
1177 let real_outputs = self.output_count_in_pool(PoolType::IRONWOOD)
1178 + self.change_count_in_pool(PoolType::IRONWOOD);
1179 let target_actions = real_outputs + dummy_outputs.ironwood();
1180
1181 // `pad_to_minimum` is only an 8-bit floor. If the requested transaction is larger than
1182 // that, its real spends and outputs already force the required action count, so no floor
1183 // is needed.
1184 u8::try_from(target_actions).map_or(BundlePadding::UNPADDED, |minimum| BundlePadding {
1185 bundle_required: target_actions > 0
1186 && self.input_count_in_pool(PoolType::IRONWOOD) == 0
1187 && real_outputs == 0,
1188 pad_to_minimum: Some(minimum.max(1)),
1189 })
1190 }
1191
1192 #[cfg(feature = "orchard")]
1193 fn orchard_style_action_count(
1194 &self,
1195 pool_type: PoolType,
1196 padding: BundlePadding,
1197 bundle_version: ::orchard::bundle::BundleVersion,
1198 ) -> Result<usize, &'static str> {
1199 crate::fees::orchard::transactional_action_count(
1200 padding.bundle_type(),
1201 bundle_version,
1202 self.input_count_in_pool(pool_type),
1203 self.output_count_in_pool(pool_type) + self.change_count_in_pool(pool_type),
1204 )
1205 }
1206
1207 /// Returns the number of actions the transaction builder will produce for this step's
1208 /// Orchard-pool bundle, given the bundle padding and version it will be configured with.
1209 ///
1210 /// The count depends upon the bundle version: prior to NU6.3, an action may carry both a
1211 /// spend and an output, so a bundle requires `max(spends, outputs)` actions; from NU6.3
1212 /// onwards the Orchard pool disables cross-address transfers, and each requested spend and
1213 /// output is instead paired with a fabricated zero-valued counterpart, so a bundle requires
1214 /// `spends + outputs` actions. The padding determines the floor applied on top of that count.
1215 ///
1216 /// The caller must pass the same padding and version the transaction builder will be
1217 /// configured with, otherwise the count will not match the bundle that is built.
1218 /// [`bundle_version_for_branch`] returns the version applicable to a given consensus branch
1219 /// and pool. The Orchard bundle is always padded to the default floor; only the Ironwood
1220 /// bundle's padding varies, and [`Step::ironwood_bundle_padding`] derives it.
1221 ///
1222 /// A step describes a wallet spend, which is never a coinbase transaction, so this takes a
1223 /// [`BundlePadding`] rather than a full bundle type: coinbase construction is a property of the
1224 /// whole transaction and is not representable here.
1225 ///
1226 /// # Errors
1227 ///
1228 /// Returns an error if this step's Orchard spend and output counts are incompatible with
1229 /// the given padding and version.
1230 ///
1231 /// [`bundle_version_for_branch`]: zcash_primitives::transaction::components::orchard::bundle_version_for_branch
1232 #[cfg(feature = "orchard")]
1233 pub fn orchard_action_count(
1234 &self,
1235 padding: BundlePadding,
1236 bundle_version: ::orchard::bundle::BundleVersion,
1237 ) -> Result<usize, &'static str> {
1238 self.orchard_style_action_count(PoolType::ORCHARD, padding, bundle_version)
1239 }
1240
1241 /// Returns the number of actions the transaction builder will produce for this step's
1242 /// Ironwood-pool bundle, given the bundle padding and version it will be configured with.
1243 ///
1244 /// See [`Step::orchard_action_count`] for how the count is determined; the Ironwood pool
1245 /// permits cross-address transfers, so an Ironwood bundle requires
1246 /// `max(spends, outputs)` actions before padding.
1247 ///
1248 /// # Errors
1249 ///
1250 /// Returns an error if this step's Ironwood spend and output counts are incompatible with
1251 /// the given padding and version.
1252 #[cfg(feature = "orchard")]
1253 pub fn ironwood_action_count(
1254 &self,
1255 padding: BundlePadding,
1256 bundle_version: ::orchard::bundle::BundleVersion,
1257 ) -> Result<usize, &'static str> {
1258 self.orchard_style_action_count(PoolType::IRONWOOD, padding, bundle_version)
1259 }
1260
1261 /// Returns a conservative upper-bound estimate of the serialized byte size of the
1262 /// transaction this step describes.
1263 ///
1264 /// `prior_steps` is the slice of all steps in the proposal (the same slice
1265 /// [`Step::from_parts`] receives), used to resolve the pool of each prior-step
1266 /// output this step spends. The caller — typically [`Proposal::check_transaction_size`]
1267 /// — passes the full proposal's step list so `step_index()` always resolves.
1268 ///
1269 /// The estimate sums the per-element byte costs of every input, output, and change
1270 /// output the step carries — including prior-step inputs and dummy outputs — plus
1271 /// the per-bundle overhead that each present bundle contributes (header fields,
1272 /// binding signatures, and the Orchard/Ironwood bundle proof). Each per-element cost
1273 /// is an upper bound:
1274 /// - Transparent inputs use their actual known serialized size (so P2SH inputs,
1275 /// which can be larger than the P2PKH standard, are not under-counted), with the
1276 /// consensus-maximum input size for inputs whose script size is unknown.
1277 /// - Sapling uses the v4 serialized form, which carries proofs and signatures
1278 /// inline — a conservative upper bound, since v5 moves these to bundle-level
1279 /// fields.
1280 /// - Orchard and Ironwood use [`ACTION_SIZE`] for each action description, plus
1281 /// the per-action spend authorization signature and the per-action proof share,
1282 /// computed via [`orchard::Proof::expected_proof_size`].
1283 ///
1284 /// The per-bundle overhead is added only when a bundle is present, so a
1285 /// purely-transparent transaction is not charged for Sapling or Orchard proof
1286 /// data it does not carry. Action counts account for both the default padding floor
1287 /// (`spends + outputs + 2`) and any dummy outputs the change strategy recorded via
1288 /// [`TransactionBalance::dummy_outputs`], taking the larger so a nonzero dummy count
1289 /// is never under-counted.
1290 ///
1291 /// The estimate is conservative (it never under-counts), so comparing it against
1292 /// [`MAX_BLOCK_BYTES`] is a safe size bound: a step whose estimate fits within a
1293 /// block will fit when built, and a step whose estimate exceeds a block may or may
1294 /// not fit, but rejecting the latter at proposal time prevents input selection from
1295 /// constructing a transaction that can never be mined. Passing the gate is necessary
1296 /// but not sufficient for mineability: `MAX_BLOCK_BYTES` is the *block* size limit,
1297 /// and a transaction that fits just under it still cannot be mined because the block
1298 /// must also carry its header and coinbase transaction. The builder is the final
1299 /// authority.
1300 ///
1301 /// [`ACTION_SIZE`]: zcash_primitives::transaction::components::orchard::ACTION_SIZE
1302 /// [`MAX_BLOCK_BYTES`]: zcash_protocol::constants::MAX_BLOCK_BYTES
1303 pub(crate) fn estimated_serialized_size(
1304 &self,
1305 resolve_prior_pool: &impl Fn(&StepOutput) -> PoolType,
1306 ) -> usize {
1307 // The maximum padding the default Orchard/Ironwood bundle type applies.
1308 #[cfg(feature = "orchard")]
1309 const ORCHARD_PADDING_ACTIONS: usize = 2;
1310
1311 // Single-pass tally of shielded inputs by pool, counting both the step's own
1312 // shielded inputs and prior-step inputs that spend shielded outputs.
1313 let mut sapling_spends = 0usize;
1314 let mut orchard_spends = 0usize;
1315 let mut ironwood_spends = 0usize;
1316
1317 if let Some(shielded) = self.shielded_inputs() {
1318 for note in shielded.notes().iter() {
1319 match note.note().pool() {
1320 ShieldedPool::Sapling => sapling_spends += 1,
1321 ShieldedPool::Orchard => orchard_spends += 1,
1322 ShieldedPool::Ironwood => ironwood_spends += 1,
1323 }
1324 }
1325 }
1326
1327 // Prior-step inputs: each references an output of a preceding step. The closure
1328 // resolves the pool of the referenced output. For transparent Payment-sourced
1329 // prior inputs, use the consensus maximum input size (the recipient could be
1330 // P2SH with a large scriptSig); for Change-sourced transparent prior inputs,
1331 // use the P2PKH standard (ephemeral change outputs are wallet-internal P2PKH).
1332 let mut prior_transparent_input_bytes = 0usize;
1333 for s_ref in self.prior_step_inputs() {
1334 let pool = resolve_prior_pool(s_ref);
1335 match pool {
1336 PoolType::Transparent => {
1337 let size = match s_ref.output_index() {
1338 StepOutputIndex::Payment(_) => MAX_TRANSPARENT_INPUT_SIZE,
1339 StepOutputIndex::Change(_) => P2PKH_STANDARD_INPUT_SIZE,
1340 };
1341 prior_transparent_input_bytes += size;
1342 }
1343 PoolType::SAPLING => sapling_spends += 1,
1344 PoolType::ORCHARD => orchard_spends += 1,
1345 PoolType::IRONWOOD => ironwood_spends += 1,
1346 }
1347 }
1348
1349 // Transparent inputs: use each input's actual serialized size where known, so
1350 // that P2SH inputs are not under-counted. For inputs whose script size is
1351 // unknown, use the consensus-maximum input size.
1352 let transparent_input_bytes = self
1353 .transparent_inputs()
1354 .iter()
1355 .map(
1356 |input| match transparent_fees::InputView::serialized_size(input) {
1357 transparent_fees::InputSize::Known(size) => size,
1358 transparent_fees::InputSize::Unknown(_) => MAX_TRANSPARENT_INPUT_SIZE,
1359 },
1360 )
1361 .sum::<usize>();
1362
1363 // Transparent outputs.
1364 let transparent_payment_outputs =
1365 self.output_count_in_pool(PoolType::Transparent) * P2PKH_STANDARD_OUTPUT_SIZE;
1366 let transparent_change_outputs =
1367 self.change_count_in_pool(PoolType::Transparent) * P2PKH_STANDARD_OUTPUT_SIZE;
1368 let transparent_present = !self.transparent_inputs().is_empty()
1369 || prior_transparent_input_bytes > 0
1370 || transparent_payment_outputs > 0
1371 || transparent_change_outputs > 0;
1372
1373 // Dummy outputs recorded by the change strategy. These pad the shielded bundles
1374 // beyond the default `spends + outputs + 2` floor; the estimate must charge for
1375 // them so a nonzero dummy count is never under-counted.
1376 let dummy = self.balance().dummy_outputs();
1377
1378 // Sapling spends and outputs. The default bundle type pads to at least two
1379 // outputs when there are any spends or outputs; adding 2 to each count covers
1380 // the padding floor. If the change strategy recorded dummy Sapling outputs,
1381 // charge for those too.
1382 let sapling_outputs = self.output_count_in_pool(PoolType::SAPLING)
1383 + self.change_count_in_pool(PoolType::SAPLING);
1384 let sapling_dummy = dummy.map_or(0, |d| d.sapling());
1385 let sapling_bundle_present = sapling_spends > 0 || sapling_outputs > 0 || sapling_dummy > 0;
1386 let sapling_spend_bytes = if sapling_bundle_present {
1387 (sapling_spends + 2) * SPEND_DESCRIPTION_SIZE
1388 } else {
1389 0
1390 };
1391 let sapling_output_bytes = if sapling_bundle_present {
1392 (sapling_outputs + 2 + sapling_dummy) * OUTPUT_DESCRIPTION_SIZE
1393 } else {
1394 0
1395 };
1396
1397 // Orchard and Ironwood bundles. Each action costs `ACTION_SIZE` plus the
1398 // per-action spend authorization signature; the bundle proof is
1399 // `Proof::expected_proof_size(num_actions)` (a fixed base plus a per-action
1400 // share), computed via the orchard crate's own `pub const fn`.
1401 #[cfg(feature = "orchard")]
1402 let orchard_bundle_bytes = orchard_style_bundle_bytes(
1403 orchard_spends,
1404 self.output_count_in_pool(PoolType::ORCHARD)
1405 + self.change_count_in_pool(PoolType::ORCHARD),
1406 dummy.map_or(0, |d| d.orchard()),
1407 ORCHARD_PADDING_ACTIONS,
1408 );
1409 #[cfg(not(feature = "orchard"))]
1410 let orchard_bundle_bytes: usize = 0;
1411 #[cfg(not(feature = "orchard"))]
1412 let _ = (orchard_spends, ironwood_spends);
1413
1414 #[cfg(feature = "orchard")]
1415 let ironwood_bundle_bytes = orchard_style_bundle_bytes(
1416 ironwood_spends,
1417 self.output_count_in_pool(PoolType::IRONWOOD)
1418 + self.change_count_in_pool(PoolType::IRONWOOD),
1419 dummy.map_or(0, |d| d.ironwood()),
1420 ORCHARD_PADDING_ACTIONS,
1421 );
1422 #[cfg(not(feature = "orchard"))]
1423 let ironwood_bundle_bytes: usize = 0;
1424
1425 TX_HEADER_SIZE
1426 + (transparent_present as usize) * TRANSPARENT_BUNDLE_OVERHEAD
1427 + transparent_input_bytes
1428 + prior_transparent_input_bytes
1429 + transparent_payment_outputs
1430 + transparent_change_outputs
1431 + (sapling_bundle_present as usize) * SAPLING_BUNDLE_OVERHEAD
1432 + sapling_spend_bytes
1433 + sapling_output_bytes
1434 + orchard_bundle_bytes
1435 + ironwood_bundle_bytes
1436 }
1437}
1438
1439/// Computes the estimated byte size of an Orchard-family bundle (Orchard or Ironwood)
1440/// for the given pool, given the number of shielded spends, real outputs, and dummy
1441/// outputs the step directs to that pool. The action count is
1442/// `max(spends + outputs + padding, outputs + dummy)`, so a nonzero dummy count is
1443/// never under-counted.
1444#[cfg(feature = "orchard")]
1445fn orchard_style_bundle_bytes(
1446 spends: usize,
1447 outputs: usize,
1448 dummy_outputs: usize,
1449 padding_actions: usize,
1450) -> usize {
1451 let default_count = spends + outputs + padding_actions;
1452 let dummy_count = outputs + dummy_outputs;
1453 let num_actions = default_count.max(dummy_count);
1454 if num_actions > 0 {
1455 ORCHARD_BUNDLE_OVERHEAD
1456 + Proof::expected_proof_size(num_actions)
1457 + num_actions * (ORCHARD_ACTION_SIZE + ORCHARD_AUTH_SIG_SIZE)
1458 } else {
1459 0
1460 }
1461}
1462
1463impl<NoteRef> Debug for Step<NoteRef> {
1464 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1465 f.debug_struct("Step")
1466 .field("transaction_request", &self.transaction_request)
1467 .field("transparent_inputs", &self.transparent_inputs)
1468 .field(
1469 "shielded_inputs",
1470 &self.shielded_inputs().map(|i| i.notes.len()),
1471 )
1472 .field("prior_step_inputs", &self.prior_step_inputs)
1473 .field("anchor_height", &self.anchor_height)
1474 .field("balance", &self.balance)
1475 .field("is_shielding", &self.is_shielding)
1476 .finish_non_exhaustive()
1477 }
1478}
1479
1480#[cfg(all(test, feature = "orchard"))]
1481mod tests {
1482 use std::collections::BTreeMap;
1483
1484 use zcash_keys::address::{Address, UnifiedAddress};
1485
1486 use incrementalmerkletree::Position;
1487 use nonempty::NonEmpty;
1488 use zcash_address::ZcashAddress;
1489 use zcash_primitives::transaction::{
1490 TxId,
1491 builder::BundlePadding,
1492 components::orchard::{ACTION_SIZE, bundle_version_for_branch},
1493 };
1494
1495 use crate::{
1496 data_api::{
1497 anchor_retention::{AnchorRetentionInterval, PoolMigrationParams},
1498 wallet::{ConfirmationsPolicy, TargetHeight},
1499 },
1500 fees::{ChangeValue, DummyOutputCounts, TransactionBalance},
1501 wallet::Note,
1502 };
1503 use zcash_protocol::{
1504 PoolType, ShieldedPool,
1505 consensus::{BlockHeight, BranchId, Network, NetworkUpgrade, Parameters},
1506 constants::MAX_BLOCK_BYTES,
1507 value::{COIN, Zatoshis},
1508 zip318::PoolMigrationConstants,
1509 };
1510 use zip321::{Payment, TransactionRequest};
1511
1512 use orchard::{
1513 ValuePool,
1514 bundle::BundleVersion,
1515 keys::{FullViewingKey, SpendingKey},
1516 note::{Note as OrchardNote, NoteVersion, RandomSeed, Rho},
1517 value::NoteValue,
1518 };
1519 use proptest::prelude::*;
1520
1521 use super::{Proposal, ProposalError, ShieldedInputs, Step};
1522
1523 // Builds an Orchard note of the given version and value. The recipient, rho, and rseed are
1524 // fixed; only the version and value vary, which is all `Note::pool`/`Note::protocol` depend on.
1525 fn orchard_note(value: u64, version: NoteVersion) -> Option<OrchardNote> {
1526 let sk: SpendingKey = Option::from(SpendingKey::from_bytes([0x2a; 32]))?;
1527 let recipient = FullViewingKey::from(&sk).address_at(0u32, zip32::Scope::External);
1528 let rho = Option::from(Rho::from_bytes(&[0; 32]))?;
1529 let rseed = Option::from(RandomSeed::from_bytes([0x1b; 32], &rho))?;
1530 Option::from(OrchardNote::from_parts(
1531 recipient,
1532 NoteValue::from_raw(value),
1533 rho,
1534 rseed,
1535 version,
1536 ))
1537 }
1538
1539 // Wraps a list of notes as the shielded inputs of a step.
1540 fn shielded_inputs_for(notes: Vec<Note>) -> Option<ShieldedInputs<u32>> {
1541 let received = notes
1542 .into_iter()
1543 .enumerate()
1544 .map(|(i, note)| {
1545 crate::wallet::ReceivedNote::from_parts(
1546 i as u32,
1547 TxId::from_bytes([0; 32]),
1548 i as u16,
1549 note,
1550 zip32::Scope::External,
1551 Position::from(i as u64),
1552 Some(BlockHeight::from_u32(100)),
1553 None,
1554 )
1555 })
1556 .collect::<Vec<_>>();
1557 NonEmpty::from_vec(received).map(ShieldedInputs::from_parts)
1558 }
1559
1560 // Wraps a list of notes into a single `Step` whose only inputs are those shielded notes.
1561 fn step_with_notes(notes: Vec<Note>) -> Step<u32> {
1562 step_with_notes_and_change(notes, vec![])
1563 }
1564
1565 // Wraps a list of notes into a single `Step` spending those shielded notes and returning the
1566 // given change outputs. The step is constructed directly rather than via `Step::from_parts`,
1567 // so the change values need not be covered by the input values.
1568 fn step_with_notes_and_change(notes: Vec<Note>, change: Vec<ChangeValue>) -> Step<u32> {
1569 let shielded_inputs = shielded_inputs_for(notes);
1570 Step {
1571 transaction_request: TransactionRequest::empty(),
1572 payment_pools: BTreeMap::new(),
1573 transparent_inputs: vec![],
1574 shielded_inputs,
1575 anchor_height: Some(BlockHeight::from_u32(100)),
1576 prior_step_inputs: vec![],
1577 balance: TransactionBalance::new(change, Zatoshis::ZERO).unwrap(),
1578 is_shielding: false,
1579 }
1580 }
1581
1582 // Constructs a validated step spending the given notes, with no payments.
1583 fn validated_step(
1584 notes: Vec<Note>,
1585 balance: TransactionBalance,
1586 ironwood_active: bool,
1587 ) -> Result<Step<u32>, ProposalError> {
1588 Step::from_parts(
1589 &[],
1590 TransactionRequest::empty(),
1591 BTreeMap::new(),
1592 vec![],
1593 shielded_inputs_for(notes),
1594 Some(BlockHeight::from_u32(100)),
1595 vec![],
1596 balance,
1597 false,
1598 ironwood_active,
1599 )
1600 }
1601
1602 /// The canonical crossing fee under the mainnet parameters these tests use.
1603 fn canonical_fee() -> Zatoshis {
1604 crate::fees::canonical_crossing_fee(
1605 &zcash_protocol::consensus::MAIN_NETWORK,
1606 BlockHeight::from_u32(2_000_000),
1607 )
1608 .expect("the canonical shape is a valid input to the ZIP 317 rule")
1609 }
1610
1611 /// The ZIP 318 parameters a wallet retaining the specified grid would report. Tests take their
1612 /// parameters from a wallet-shaped value, as production code does; there is deliberately no
1613 /// implementation of `PoolMigrationConstants` for a network type.
1614 fn zip318() -> PoolMigrationParams {
1615 PoolMigrationParams::new(
1616 zcash_protocol::zip318::BlockSpacing::UNIFORM,
1617 AnchorRetentionInterval::ZIP_318,
1618 )
1619 }
1620
1621 fn shielded_change(pool: ShieldedPool, value: u64) -> ChangeValue {
1622 ChangeValue::shielded(pool, Zatoshis::const_from_u64(value), None)
1623 }
1624
1625 // A step paying `value` into the Ironwood pool: one payment, routed to Ironwood, funded by
1626 // Orchard notes, with the given change. This is the shape of an ordinary post-NU6.3 payment
1627 // to an Orchard receiver, which is delivered through the Ironwood bundle.
1628 fn ironwood_payment_step(value: u64, change: Vec<ChangeValue>) -> Step<u32> {
1629 let recipient: ZcashAddress =
1630 "u1qpatys4zruk99pg59gcscrt7y6akvl9vrhcfyhm9yxvxz7h87q6n8cgrzzpe9zru68uq39uhmlpp5uefxu0su5uqyqfe5zp3tycn0ecl"
1631 .parse()
1632 .expect("a valid unified address");
1633 let request = TransactionRequest::new(vec![Payment::without_memo(
1634 recipient,
1635 Zatoshis::const_from_u64(value),
1636 )])
1637 .expect("a valid transaction request");
1638
1639 Step {
1640 transaction_request: request,
1641 payment_pools: BTreeMap::from([(0usize, PoolType::IRONWOOD)]),
1642 transparent_inputs: vec![],
1643 shielded_inputs: shielded_inputs_for(orchard_and_ironwood_notes(
1644 (1, value + 100_000),
1645 (0, 0),
1646 )),
1647 // A boundary of the ZIP 318 grid: a canonical crossing must be anchored to one.
1648 anchor_height: Some(BlockHeight::from_u32(144)),
1649 prior_step_inputs: vec![],
1650 balance: TransactionBalance::new(change, canonical_fee())
1651 .unwrap()
1652 .with_dummy_outputs(DummyOutputCounts::new(0, 0, 0)),
1653 is_shielding: false,
1654 }
1655 }
1656
1657 /// A crossing whose value is a canonical ZIP 318 denomination is built with a single unpadded
1658 /// Ironwood action, matching the shape of a migration transfer.
1659 #[test]
1660 fn canonical_crossing_is_unpadded() {
1661 for value in [COIN, COIN / 2, COIN / 100, 20 * COIN, 10_000 * COIN] {
1662 let step = ironwood_payment_step(value, vec![]);
1663 assert!(
1664 step.is_canonical_crossing(&zip318(), canonical_fee()),
1665 "{value} zatoshi should be a canonical crossing"
1666 );
1667 assert_eq!(step.ironwood_bundle_padding(), BundlePadding::UNPADDED);
1668 assert_eq!(
1669 step.ironwood_action_count(
1670 step.ironwood_bundle_padding(),
1671 BundleVersion::ironwood_v3()
1672 ),
1673 Ok(1)
1674 );
1675 }
1676 }
1677
1678 /// One zatoshi off a canonical denomination, or outside the ZIP's bounds, is not a canonical
1679 /// crossing: it stays padded, since it would join no migration anonymity set.
1680 #[test]
1681 fn near_canonical_crossing_stays_padded() {
1682 for value in [COIN + 1, COIN - 1, 3 * COIN, 100_000, 20_000 * COIN] {
1683 let step = ironwood_payment_step(value, vec![]);
1684 assert!(
1685 !step.is_canonical_crossing(&zip318(), canonical_fee()),
1686 "{value} zatoshi should not be a canonical crossing"
1687 );
1688 assert_eq!(
1689 step.ironwood_action_count(BundlePadding::DEFAULT, BundleVersion::ironwood_v3()),
1690 Ok(2)
1691 );
1692 }
1693 }
1694
1695 /// Ironwood change alongside a canonical payment is NOT a canonical crossing. The fee model
1696 /// cannot see a change value at the point it decides padding — that value is what it is
1697 /// solving for — so including change here would let the two disagree and break the builder's
1698 /// exact-balance check. It costs nothing: two real outputs already meet the default floor.
1699 #[test]
1700 fn ironwood_change_is_not_a_canonical_crossing() {
1701 let step = ironwood_payment_step(COIN, vec![shielded_change(ShieldedPool::Ironwood, 5000)]);
1702 assert!(!step.is_canonical_crossing(&zip318(), canonical_fee()));
1703 }
1704
1705 /// A payment funded from Ironwood notes crosses nothing, so it is never a canonical crossing
1706 /// however canonical its value. Its bundle has two real actions regardless.
1707 #[test]
1708 fn ironwood_funded_payment_is_not_a_canonical_crossing() {
1709 let mut step = ironwood_payment_step(COIN, vec![]);
1710 step.shielded_inputs =
1711 shielded_inputs_for(orchard_and_ironwood_notes((0, 0), (1, 2 * COIN)));
1712 assert!(!step.is_canonical_crossing(&zip318(), canonical_fee()));
1713 }
1714
1715 /// An anchor off the bucket grid is not a canonical crossing, however canonical the value and
1716 /// however migration-shaped the bundles. Unpadding here would produce an "unpadded Ironwood
1717 /// against a fresh anchor" shape that no migration transfer emits, which is a fingerprint
1718 /// rather than a disguise.
1719 #[test]
1720 fn unbucketed_anchor_is_not_a_canonical_crossing() {
1721 let mut step = ironwood_payment_step(COIN, vec![]);
1722 for height in [143u32, 145, 1, 2_000_000] {
1723 step.anchor_height = Some(BlockHeight::from_u32(height));
1724 assert!(
1725 !step.is_canonical_crossing(&zip318(), canonical_fee()),
1726 "anchor {height} is not a grid boundary"
1727 );
1728 }
1729 // The neighbouring boundaries are.
1730 for height in [144u32, 288, 1_999_872] {
1731 step.anchor_height = Some(BlockHeight::from_u32(height));
1732 assert!(step.is_canonical_crossing(&zip318(), canonical_fee()));
1733 }
1734 }
1735
1736 /// A migration transfer spends exactly one Orchard note. A payment drawing on more leaves an
1737 /// Orchard bundle of the wrong size, so unpadding its Ironwood bundle would not resemble a
1738 /// migration transfer.
1739 #[test]
1740 fn multiple_orchard_inputs_are_not_a_canonical_crossing() {
1741 let mut step = ironwood_payment_step(COIN, vec![]);
1742 assert!(step.is_canonical_crossing(&zip318(), canonical_fee()));
1743
1744 step.shielded_inputs = shielded_inputs_for(orchard_and_ironwood_notes((3, COIN), (0, 0)));
1745 assert!(!step.is_canonical_crossing(&zip318(), canonical_fee()));
1746 }
1747
1748 /// Overridden ZIP 318 parameters narrow which crossings are canonical, and the padding follows.
1749 #[test]
1750 fn canonical_crossing_respects_overridden_parameters() {
1751 #[derive(Clone)]
1752 struct SmallCap;
1753 impl PoolMigrationConstants for SmallCap {
1754 fn block_spacing(&self) -> zcash_protocol::zip318::BlockSpacing {
1755 zcash_protocol::zip318::BlockSpacing::UNIFORM
1756 }
1757
1758 fn denomination_cap(&self) -> Zatoshis {
1759 Zatoshis::const_from_u64(COIN)
1760 }
1761 }
1762
1763 let step = ironwood_payment_step(2 * COIN, vec![]);
1764 assert!(step.is_canonical_crossing(&zip318(), canonical_fee()));
1765 assert!(!step.is_canonical_crossing(&SmallCap, canonical_fee()));
1766 }
1767
1768 /// A step that produces any shielded bundle must bind a concrete anchor. Passing `None` (the
1769 /// decoded state of the wire-format zero sentinel) for such a step is rejected, whether the
1770 /// shielded bundle comes from spent notes or from shielded outputs. Only a purely transparent
1771 /// step may carry `None`.
1772 #[test]
1773 fn shielded_step_requires_anchor() {
1774 // A step that spends shielded notes with no anchor is rejected.
1775 assert_matches!(
1776 Step::from_parts(
1777 &[],
1778 TransactionRequest::empty(),
1779 BTreeMap::new(),
1780 vec![],
1781 shielded_inputs_for(orchard_and_ironwood_notes((1, 10_000), (0, 0))),
1782 None,
1783 vec![],
1784 TransactionBalance::new(vec![], Zatoshis::const_from_u64(10_000)).unwrap(),
1785 false,
1786 false,
1787 ),
1788 Err(ProposalError::MissingShieldedAnchor)
1789 );
1790
1791 // A step with a shielded output but no shielded inputs must also bind an anchor: the dummy
1792 // spends padding the output bundle commit to it.
1793 assert_matches!(
1794 Step::from_parts(
1795 &[],
1796 TransactionRequest::empty(),
1797 BTreeMap::new(),
1798 vec![],
1799 None::<ShieldedInputs<u32>>,
1800 None,
1801 vec![],
1802 TransactionBalance::new(
1803 vec![shielded_change(ShieldedPool::Orchard, 10_000)],
1804 Zatoshis::ZERO,
1805 )
1806 .unwrap(),
1807 false,
1808 false,
1809 ),
1810 Err(ProposalError::MissingShieldedAnchor)
1811 );
1812
1813 // A purely transparent step may carry no anchor.
1814 assert_matches!(
1815 Step::from_parts(
1816 &[],
1817 TransactionRequest::empty(),
1818 BTreeMap::new(),
1819 vec![],
1820 None::<ShieldedInputs<u32>>,
1821 None,
1822 vec![],
1823 TransactionBalance::new(vec![], Zatoshis::ZERO).unwrap(),
1824 false,
1825 false,
1826 ),
1827 Ok(step) if step.anchor_height().is_none()
1828 );
1829 }
1830
1831 /// Proposal construction conserves value: the total output value of a step (payments +
1832 /// change + fee) may never exceed its total input value. A step whose outputs exceed its
1833 /// inputs is rejected with [`ProposalError::BalanceError`] rather than being constructed.
1834 /// This is the value-conservation floor that pool-selection policy builds on: no selection
1835 /// of inputs can ever be assembled into a proposal that spends more than it takes in.
1836 #[test]
1837 fn proposal_construction_conserves_value() {
1838 // Inputs: one 10_000 Orchard note. Outputs: 8_000 change + 4_000 fee = 12_000, which
1839 // exceeds the 10_000 of input value. (ironwood_active = false so the balance check,
1840 // not the turnstile, is what rejects this.)
1841 assert_matches!(
1842 validated_step(
1843 orchard_and_ironwood_notes((1, 10_000), (0, 0)),
1844 TransactionBalance::new(
1845 vec![shielded_change(ShieldedPool::Orchard, 8_000)],
1846 Zatoshis::const_from_u64(4_000),
1847 )
1848 .unwrap(),
1849 false,
1850 ),
1851 Err(ProposalError::BalanceError {
1852 input_total,
1853 output_total,
1854 }) if input_total == Zatoshis::const_from_u64(10_000)
1855 && output_total == Zatoshis::const_from_u64(12_000)
1856 );
1857
1858 // The matching balanced step (6_000 change + 4_000 fee == 10_000 input) is accepted,
1859 // confirming the rejection above is due to the value imbalance and not some unrelated
1860 // constraint.
1861 assert_matches!(
1862 validated_step(
1863 orchard_and_ironwood_notes((1, 10_000), (0, 0)),
1864 TransactionBalance::new(
1865 vec![shielded_change(ShieldedPool::Orchard, 6_000)],
1866 Zatoshis::const_from_u64(4_000),
1867 )
1868 .unwrap(),
1869 false,
1870 ),
1871 Ok(_)
1872 );
1873 }
1874
1875 /// A proposal whose estimated serialized size exceeds the maximum a Zcash block may
1876 /// carry is rejected by `Proposal::check_transaction_size`, rather than being allowed
1877 /// to reach the builder.
1878 ///
1879 /// `Step::from_parts` accepts the step (it does not enforce the size gate), and
1880 /// `Proposal::check_transaction_size` rejects it — confirming the gate is in the
1881 /// proposal-level check, not the constructor, so the decode path is not broken.
1882 #[test]
1883 fn proposal_exceeding_the_block_size_limit_is_rejected() {
1884 let proof_per_action =
1885 orchard::Proof::expected_proof_size(2) - orchard::Proof::expected_proof_size(1);
1886 let full_action_cost = ACTION_SIZE + 64 + proof_per_action;
1887 let oversize_spend_count = MAX_BLOCK_BYTES / full_action_cost + 1;
1888
1889 let one_zato = Zatoshis::const_from_u64(1);
1890 let input_total = (one_zato * oversize_spend_count).unwrap();
1891 let change = ChangeValue::shielded(ShieldedPool::Orchard, input_total, None);
1892 let balance = TransactionBalance::new(vec![change], Zatoshis::ZERO).unwrap();
1893
1894 // `from_parts` accepts the step (no size gate).
1895 let step = validated_step(
1896 orchard_and_ironwood_notes((oversize_spend_count, 1), (0, 0)),
1897 balance,
1898 false,
1899 )
1900 .expect("from_parts accepts oversized steps");
1901
1902 let proposal = Proposal::multi_step(
1903 (),
1904 TargetHeight::from(100),
1905 ConfirmationsPolicy::default(),
1906 NonEmpty::singleton(step),
1907 )
1908 .expect("multi_step accepts a valid step");
1909
1910 // `check_transaction_size` rejects it.
1911 assert_matches!(
1912 proposal.check_transaction_size(),
1913 Err(ProposalError::TransactionTooLarge {
1914 estimated_size,
1915 limit,
1916 sapling_input_count,
1917 #[cfg(feature = "orchard")]
1918 orchard_input_count,
1919 #[cfg(feature = "orchard")]
1920 ironwood_input_count,
1921 }) if limit == MAX_BLOCK_BYTES
1922 && estimated_size > MAX_BLOCK_BYTES
1923 && sapling_input_count == 0
1924 && orchard_input_count == oversize_spend_count
1925 && ironwood_input_count == 0
1926 );
1927 }
1928
1929 /// A proposal whose estimated size fits within the block limit is accepted by
1930 /// `check_transaction_size`.
1931 #[test]
1932 fn proposal_fitting_within_the_block_size_limit_is_accepted() {
1933 let proof_per_action =
1934 orchard::Proof::expected_proof_size(2) - orchard::Proof::expected_proof_size(1);
1935 let full_action_cost = ACTION_SIZE + 64 + proof_per_action;
1936 let fitting_spend_count = MAX_BLOCK_BYTES / full_action_cost - 8;
1937
1938 let one_zato = Zatoshis::const_from_u64(1);
1939 let input_total = (one_zato * fitting_spend_count).unwrap();
1940 let change = ChangeValue::shielded(ShieldedPool::Orchard, input_total, None);
1941 let balance = TransactionBalance::new(vec![change], Zatoshis::ZERO).unwrap();
1942
1943 let step = validated_step(
1944 orchard_and_ironwood_notes((fitting_spend_count, 1), (0, 0)),
1945 balance,
1946 false,
1947 )
1948 .expect("from_parts accepts steps that fit");
1949
1950 let proposal = Proposal::multi_step(
1951 (),
1952 TargetHeight::from(100),
1953 ConfirmationsPolicy::default(),
1954 NonEmpty::singleton(step),
1955 )
1956 .expect("multi_step accepts a valid step");
1957
1958 assert_matches!(proposal.check_transaction_size(), Ok(()));
1959 }
1960
1961 #[test]
1962 fn orchard_turnstile_permits_only_strict_pool_balance_decrease() {
1963 // Post-activation, change may return to Orchard when strictly less value returns
1964 // than the step's Orchard inputs remove: 6_000 change < 10_000 input.
1965 assert_matches!(
1966 validated_step(
1967 orchard_and_ironwood_notes((1, 10_000), (0, 0)),
1968 TransactionBalance::new(
1969 vec![shielded_change(ShieldedPool::Orchard, 6_000)],
1970 Zatoshis::const_from_u64(4_000),
1971 )
1972 .unwrap(),
1973 true,
1974 ),
1975 Ok(_)
1976 );
1977
1978 // Post-activation, Orchard change equal to the Orchard input total would leave the
1979 // pool balance unchanged, which the turnstile forbids.
1980 assert_matches!(
1981 validated_step(
1982 orchard_and_ironwood_notes((1, 10_000), (1, 20_000)),
1983 TransactionBalance::new(
1984 vec![
1985 shielded_change(ShieldedPool::Orchard, 10_000),
1986 shielded_change(ShieldedPool::Ironwood, 16_000),
1987 ],
1988 Zatoshis::const_from_u64(4_000),
1989 )
1990 .unwrap(),
1991 true,
1992 ),
1993 Err(ProposalError::OrchardPoolValueCreation {
1994 input_total,
1995 output_total,
1996 }) if input_total == Zatoshis::const_from_u64(10_000)
1997 && output_total == Zatoshis::const_from_u64(10_000)
1998 );
1999
2000 // Post-activation, a step that spends no Orchard notes may not create Orchard
2001 // change at all.
2002 assert_matches!(
2003 validated_step(
2004 orchard_and_ironwood_notes((0, 0), (1, 20_000)),
2005 TransactionBalance::new(
2006 vec![shielded_change(ShieldedPool::Orchard, 16_000)],
2007 Zatoshis::const_from_u64(4_000),
2008 )
2009 .unwrap(),
2010 true,
2011 ),
2012 Err(ProposalError::OrchardPoolValueCreation {
2013 input_total,
2014 output_total,
2015 }) if input_total == Zatoshis::ZERO
2016 && output_total == Zatoshis::const_from_u64(16_000)
2017 );
2018
2019 // Before activation, value may freely enter the Orchard pool: the same step is
2020 // valid.
2021 assert_matches!(
2022 validated_step(
2023 orchard_and_ironwood_notes((0, 0), (1, 20_000)),
2024 TransactionBalance::new(
2025 vec![shielded_change(ShieldedPool::Orchard, 16_000)],
2026 Zatoshis::const_from_u64(4_000),
2027 )
2028 .unwrap(),
2029 false,
2030 ),
2031 Ok(_)
2032 );
2033 }
2034
2035 // Constructs a step that spends a 10_000-zatoshi Orchard note to pay 6_000 zatoshis to
2036 // an Orchard receiver, with the payment assigned to the given pool.
2037 fn orchard_payment_step(
2038 pool: PoolType,
2039 ironwood_active: bool,
2040 ) -> Result<Step<u32>, ProposalError> {
2041 let sk: SpendingKey = Option::from(SpendingKey::from_bytes([0x2a; 32])).unwrap();
2042 let recipient = FullViewingKey::from(&sk).address_at(0u32, zip32::Scope::External);
2043 let ua = UnifiedAddress::from_receivers(Some(recipient), None, None, None, None).unwrap();
2044 let to = Address::Unified(Box::new(ua)).to_zcash_address(&Network::TestNetwork);
2045
2046 let request = TransactionRequest::new(vec![
2047 zip321::Payment::new(
2048 to,
2049 Some(Zatoshis::const_from_u64(6_000)),
2050 None,
2051 None,
2052 None,
2053 vec![],
2054 )
2055 .unwrap(),
2056 ])
2057 .unwrap();
2058
2059 Step::from_parts(
2060 &[],
2061 request,
2062 BTreeMap::from([(0usize, pool)]),
2063 vec![],
2064 shielded_inputs_for(orchard_and_ironwood_notes((1, 10_000), (0, 0))),
2065 Some(BlockHeight::from_u32(100)),
2066 vec![],
2067 TransactionBalance::new(vec![], Zatoshis::const_from_u64(4_000)).unwrap(),
2068 false,
2069 ironwood_active,
2070 )
2071 }
2072
2073 #[test]
2074 fn orchard_turnstile_permits_routed_and_pre_activation_payments() {
2075 // A payment routed through the Ironwood bundle (the post-activation representation
2076 // of an Orchard-receiver payment) is valid.
2077 assert_matches!(orchard_payment_step(PoolType::IRONWOOD, true), Ok(_));
2078
2079 // Before activation, an Orchard-pool payment is valid.
2080 assert_matches!(orchard_payment_step(PoolType::ORCHARD, false), Ok(_));
2081 }
2082
2083 // Post-activation, payment classification never assigns a payment to the Orchard pool,
2084 // so a step constructed with one can only arise through direct misuse of the public
2085 // constructor, which rejects it.
2086 #[test]
2087 fn orchard_pool_payment_with_ironwood_active_is_an_error() {
2088 assert_matches!(
2089 orchard_payment_step(PoolType::ORCHARD, true),
2090 Err(ProposalError::OrchardPoolPayment(0))
2091 );
2092 }
2093
2094 // Builds `orchard.0` version-2 (Orchard) notes of value `orchard.1`, followed by `ironwood.0`
2095 // version-3 (Ironwood) notes of value `ironwood.1`.
2096 //
2097 // Every note of a given pool is identical, so each is derived once and cloned: deriving a full
2098 // viewing key per note makes the large-transaction cases below cost seconds rather than
2099 // milliseconds. Only the count and the pool of each note matter to an action count.
2100 fn orchard_and_ironwood_notes(
2101 (n, orchard_value): (usize, u64),
2102 (m, ironwood_value): (usize, u64),
2103 ) -> Vec<Note> {
2104 let mut notes = Vec::with_capacity(n + m);
2105 if n > 0 {
2106 let note = Note::Orchard {
2107 note: orchard_note(orchard_value, NoteVersion::V2).unwrap(),
2108 pool: ValuePool::Orchard,
2109 };
2110 notes.extend(std::iter::repeat_n(note, n));
2111 }
2112 if m > 0 {
2113 let note = Note::Orchard {
2114 note: orchard_note(ironwood_value, NoteVersion::V3).unwrap(),
2115 pool: ValuePool::Ironwood,
2116 };
2117 notes.extend(std::iter::repeat_n(note, m));
2118 }
2119 notes
2120 }
2121
2122 // Note and change values, spanning a zero-valued note through a large one. An action count is a
2123 // function of how many notes and outputs a step has in each pool, never of what they are worth,
2124 // so every value here must give the same answer. The upper bound keeps the total value of even
2125 // the largest generated step well inside `MAX_MONEY`, so that `TransactionBalance` construction
2126 // cannot fail for a reason these tests are not about.
2127 fn arb_note_value() -> impl Strategy<Value = u64> {
2128 prop_oneof![
2129 1 => Just(0u64),
2130 8 => 1u64..1_000_000_000,
2131 ]
2132 }
2133
2134 // An upper bound on the actions a single bundle can contain on-chain: a transaction may not
2135 // exceed a block, and each action costs at least its action description. The true ceiling is
2136 // lower, since `ACTION_SIZE` excludes each action's spend authorization signature and the
2137 // bundle's proof, but an over-estimate is what these tests want: it exercises counts at least
2138 // as large as anything buildable.
2139 const MAX_ACTIONS_PER_BUNDLE: usize = MAX_BLOCK_BYTES / ACTION_SIZE;
2140
2141 // Spend and output counts spanning what a wallet can actually produce: ordinary payments, large
2142 // note-consolidation transactions, and bundles at the block ceiling. Each side is capped at
2143 // half the ceiling, because from NU6.3 an Orchard spend and an output no longer share an
2144 // action, so `spends + outputs` must itself fit. Small counts are weighted heavily: that is
2145 // where the padding floor binds, and where real transactions live.
2146 fn arb_note_count() -> impl Strategy<Value = usize> {
2147 prop_oneof![
2148 6 => 0usize..8,
2149 2 => 8usize..100,
2150 1 => 100usize..=MAX_ACTIONS_PER_BUNDLE / 2,
2151 ]
2152 }
2153
2154 // The network upgrades at which the Orchard pool exists but still permits cross-address
2155 // transfers, so that a requested spend and a requested output may share an action.
2156 fn arb_pre_nu6_3_upgrade() -> impl Strategy<Value = NetworkUpgrade> {
2157 prop::sample::select(vec![
2158 NetworkUpgrade::Nu5,
2159 NetworkUpgrade::Nu6,
2160 NetworkUpgrade::Nu6_1,
2161 NetworkUpgrade::Nu6_2,
2162 ])
2163 }
2164
2165 // The network upgrades from which the Orchard pool disables cross-address transfers.
2166 fn arb_nu6_3_or_later_upgrade() -> impl Strategy<Value = NetworkUpgrade> {
2167 prop::sample::select(vec![NetworkUpgrade::Nu6_3, NetworkUpgrade::Nu7])
2168 }
2169
2170 // Resolves the bundle version applicable to a pool at an upgrade's testnet activation height,
2171 // the way the fee and builder paths do: height -> consensus branch -> `BundleVersion`.
2172 fn bundle_version_at(upgrade: NetworkUpgrade, pool: ValuePool) -> BundleVersion {
2173 let height = Network::TestNetwork.activation_height(upgrade).unwrap();
2174 bundle_version_for_branch(BranchId::for_height(&Network::TestNetwork, height), pool)
2175 .unwrap()
2176 }
2177
2178 // A step that requires no actions in a pool produces no bundle in that pool, so it is charged
2179 // nothing, no matter how much padding the bundle type would otherwise apply. The exception is
2180 // a bundle type that requires a bundle: the builder then produces one consisting entirely of
2181 // dummy actions, padded to the type's minimum.
2182 #[test]
2183 fn uninvolved_pool_is_charged_nothing_unless_a_bundle_is_required() {
2184 let step = step_with_notes(vec![]);
2185 for bundle_type in [BundlePadding::DEFAULT, BundlePadding::UNPADDED] {
2186 assert_eq!(
2187 step.orchard_action_count(bundle_type, BundleVersion::orchard_v3()),
2188 Ok(0)
2189 );
2190 assert_eq!(
2191 step.ironwood_action_count(bundle_type, BundleVersion::ironwood_v3()),
2192 Ok(0)
2193 );
2194 }
2195
2196 // `bundle_required` guarantees a bundle exists, padded to the type's minimum: two
2197 // all-dummy actions by default, or one when the type opts out of the default padding.
2198 let required = BundlePadding {
2199 bundle_required: true,
2200 pad_to_minimum: None,
2201 };
2202 assert_eq!(
2203 step.orchard_action_count(required, BundleVersion::orchard_v3()),
2204 Ok(2)
2205 );
2206 let required_unpadded = BundlePadding {
2207 bundle_required: true,
2208 pad_to_minimum: Some(1),
2209 };
2210 assert_eq!(
2211 step.orchard_action_count(required_unpadded, BundleVersion::orchard_v3()),
2212 Ok(1)
2213 );
2214
2215 // A zero floor cannot suppress a required bundle: a bundle must contain at least one
2216 // action to exist at all, so the required-but-unpadded-to-zero case still yields one.
2217 let required_zero_floor = BundlePadding {
2218 bundle_required: true,
2219 pad_to_minimum: Some(0),
2220 };
2221 assert_eq!(
2222 step.orchard_action_count(required_zero_floor, BundleVersion::orchard_v3()),
2223 Ok(1)
2224 );
2225
2226 // Without `bundle_required`, a zero floor leaves an uninvolved pool with no bundle.
2227 let zero_floor = BundlePadding {
2228 bundle_required: false,
2229 pad_to_minimum: Some(0),
2230 };
2231 assert_eq!(
2232 step.orchard_action_count(zero_floor, BundleVersion::orchard_v3()),
2233 Ok(0)
2234 );
2235 }
2236
2237 // `spends_cannot_be_charged_to_a_coinbase_bundle` used to live here, asserting that a step
2238 // which spends notes is rejected when charged to a coinbase bundle of that pool. The action
2239 // counters now take a `BundlePadding`, which has no coinbase variant, so that case is
2240 // unrepresentable rather than merely rejected: a step describes a wallet spend, and a wallet
2241 // spend is never a coinbase transaction.
2242
2243 // What NU6.3 costs a large transaction depends on its shape, and a wallet estimating fees at
2244 // scale has to get the difference right.
2245 #[test]
2246 fn nu6_3_action_growth_at_scale_depends_on_step_shape() {
2247 let balanced = |n| {
2248 step_with_notes_and_change(
2249 orchard_and_ironwood_notes((n, 10_000), (0, 0)),
2250 std::iter::repeat_n(shielded_change(ShieldedPool::Orchard, 10_000), n).collect(),
2251 )
2252 };
2253
2254 // A consolidation -- sweeping many notes into a single change output -- pays for that
2255 // output's fabricated spend and nothing else: one extra action, however large it is.
2256 let consolidation = step_with_notes_and_change(
2257 orchard_and_ironwood_notes((MAX_ACTIONS_PER_BUNDLE, 10_000), (0, 0)),
2258 vec![shielded_change(ShieldedPool::Orchard, 10_000)],
2259 );
2260 // 2439 actions: the change output rides along in a spend's action, exactly filling a block.
2261 assert_eq!(
2262 consolidation
2263 .orchard_action_count(BundlePadding::UNPADDED, BundleVersion::orchard_v2()),
2264 Ok(MAX_ACTIONS_PER_BUNDLE)
2265 );
2266 // 2440 from NU6.3: one action over the ceiling, so the same sweep no longer fits.
2267 assert_eq!(
2268 consolidation
2269 .orchard_action_count(BundlePadding::UNPADDED, BundleVersion::orchard_v3()),
2270 Ok(MAX_ACTIONS_PER_BUNDLE + 1)
2271 );
2272
2273 // A balanced step -- as many outputs as spends -- doubles instead, so the largest one that
2274 // fits in a block halves at NU6.3: 2439 spends paired with 2439 outputs before, 1219 of
2275 // each after. This is why `arb_note_count` caps each side at half the ceiling.
2276 let half = MAX_ACTIONS_PER_BUNDLE / 2;
2277 assert_eq!(
2278 balanced(MAX_ACTIONS_PER_BUNDLE)
2279 .orchard_action_count(BundlePadding::UNPADDED, BundleVersion::orchard_v2()),
2280 Ok(MAX_ACTIONS_PER_BUNDLE)
2281 );
2282 // 2438: the largest balanced step that still fits.
2283 assert_eq!(
2284 balanced(half)
2285 .orchard_action_count(BundlePadding::UNPADDED, BundleVersion::orchard_v3()),
2286 Ok(2 * half)
2287 );
2288 // 2440: one note more on each side and it does not.
2289 assert_eq!(
2290 balanced(half + 1)
2291 .orchard_action_count(BundlePadding::UNPADDED, BundleVersion::orchard_v3()),
2292 Ok(2 * half + 2)
2293 );
2294 }
2295
2296 // A `Step` does not necessarily come from this wallet's own input selection: a proposal crosses
2297 // a trust boundary as a serialized message (see `proposal.proto` and
2298 // `Proposal::try_into_standard_proposal`), so a step's output and change counts are
2299 // attacker-influenced. A step claiming far more outputs than could ever fit on-chain must still
2300 // be counted exactly, so that the fee rule is handed an unaffordable action count and rejects
2301 // it, rather than a small wrapped-around one it would accept.
2302 //
2303 // The counts themselves cannot overflow `usize`: each is the length of an in-memory collection,
2304 // so reaching `usize::MAX` would require more notes than could be addressed.
2305 #[test]
2306 fn counts_beyond_the_block_limit_are_counted_exactly() {
2307 let spends = 4 * MAX_ACTIONS_PER_BUNDLE;
2308 let change = 4 * MAX_ACTIONS_PER_BUNDLE;
2309 let step = step_with_notes_and_change(
2310 orchard_and_ironwood_notes((spends, 10_000), (0, 0)),
2311 std::iter::repeat_n(shielded_change(ShieldedPool::Orchard, 10_000), change).collect(),
2312 );
2313
2314 // Roughly eight times what a block can hold, reported exactly.
2315 assert!(spends + change > 8 * MAX_ACTIONS_PER_BUNDLE - 1);
2316 assert_eq!(
2317 step.orchard_action_count(BundlePadding::UNPADDED, BundleVersion::orchard_v3()),
2318 Ok(spends + change)
2319 );
2320
2321 // Pre-NU6.3 the same step pairs, so it is charged half as much -- still far past the
2322 // ceiling, and still counted exactly.
2323 assert_eq!(
2324 step.orchard_action_count(BundlePadding::UNPADDED, BundleVersion::orchard_v2()),
2325 Ok(spends.max(change))
2326 );
2327
2328 // Padding a step this size cannot reduce it, and the ZIP 317 floor is irrelevant here.
2329 assert_eq!(
2330 step.orchard_action_count(BundlePadding::DEFAULT, BundleVersion::orchard_v3()),
2331 Ok(spends + change)
2332 );
2333 }
2334
2335 // A payment output counts towards the action count of the pool it is directed to, exactly as
2336 // a change output does. `orchard_payment_step` spends one Orchard note to make one payment.
2337 #[test]
2338 fn action_count_includes_payment_outputs() {
2339 // Pre-activation, the payment is an Orchard-pool output: one spend and one output share
2340 // an action, because the pre-NU6.3 Orchard bundle version permits cross-address transfers.
2341 let step = orchard_payment_step(PoolType::ORCHARD, false).unwrap();
2342 assert_eq!(
2343 step.orchard_action_count(BundlePadding::UNPADDED, BundleVersion::orchard_v2()),
2344 Ok(1)
2345 );
2346 assert_eq!(
2347 step.ironwood_action_count(BundlePadding::UNPADDED, BundleVersion::ironwood_v3()),
2348 Ok(0)
2349 );
2350
2351 // Post-activation, the payment is routed to the Ironwood pool: the Orchard note spend is
2352 // charged to the Orchard bundle and the payment output to the Ironwood bundle, so neither
2353 // pool can pair them into one action.
2354 let step = orchard_payment_step(PoolType::IRONWOOD, true).unwrap();
2355 assert_eq!(
2356 step.orchard_action_count(BundlePadding::UNPADDED, BundleVersion::orchard_v3()),
2357 Ok(1)
2358 );
2359 assert_eq!(
2360 step.ironwood_action_count(BundlePadding::UNPADDED, BundleVersion::ironwood_v3()),
2361 Ok(1)
2362 );
2363 }
2364
2365 proptest! {
2366 // The number of actions a step's Orchard-pool bundle requires depends upon the bundle
2367 // version: the pre-NU6.3 versions permit cross-address transfers, so a spend and an
2368 // output may share an action (`max(spends, outputs)`), while from NU6.3 onwards the
2369 // Orchard pool disables them, so each spend and output is paired with a fabricated
2370 // counterpart and occupies its own action (`spends + outputs`). The Ironwood pool
2371 // permits cross-address transfers at every version, so it always pairs.
2372 #[test]
2373 fn action_count_pairs_spends_and_outputs_only_when_cross_address_is_permitted(
2374 orchard_spends in arb_note_count(),
2375 ironwood_spends in arb_note_count(),
2376 orchard_change in arb_note_count(),
2377 ironwood_change in arb_note_count(),
2378 orchard_value in arb_note_value(),
2379 ironwood_value in arb_note_value(),
2380 change_value in arb_note_value(),
2381 ) {
2382 let change = std::iter::repeat_n(ShieldedPool::Orchard, orchard_change)
2383 .chain(std::iter::repeat_n(ShieldedPool::Ironwood, ironwood_change))
2384 .map(|pool| shielded_change(pool, change_value))
2385 .collect();
2386 let step = step_with_notes_and_change(
2387 orchard_and_ironwood_notes((orchard_spends, orchard_value), (ironwood_spends, ironwood_value)),
2388 change,
2389 );
2390
2391 // `UNPADDED` pads only to the one-action consensus minimum, so for a non-empty
2392 // bundle its action count is exactly the number of requested actions.
2393 for version in [BundleVersion::orchard_insecure_v1(), BundleVersion::orchard_v2()] {
2394 prop_assert_eq!(
2395 step.orchard_action_count(BundlePadding::UNPADDED, version),
2396 Ok(orchard_spends.max(orchard_change))
2397 );
2398 }
2399 prop_assert_eq!(
2400 step.orchard_action_count(BundlePadding::UNPADDED, BundleVersion::orchard_v3()),
2401 Ok(orchard_spends + orchard_change)
2402 );
2403 prop_assert_eq!(
2404 step.ironwood_action_count(BundlePadding::UNPADDED, BundleVersion::ironwood_v3()),
2405 Ok(ironwood_spends.max(ironwood_change))
2406 );
2407
2408 // The bundle type governs padding on top of that count: `DEFAULT` pads a non-empty
2409 // bundle up to the ZIP 317 two-action floor, and produces no bundle at all when the
2410 // step requires no actions in that pool.
2411 let pad = |requested: usize| if requested == 0 { 0 } else { requested.max(2) };
2412 prop_assert_eq!(
2413 step.orchard_action_count(BundlePadding::DEFAULT, BundleVersion::orchard_v3()),
2414 Ok(pad(orchard_spends + orchard_change))
2415 );
2416 prop_assert_eq!(
2417 step.ironwood_action_count(BundlePadding::DEFAULT, BundleVersion::ironwood_v3()),
2418 Ok(pad(ironwood_spends.max(ironwood_change)))
2419 );
2420 }
2421
2422 // The action count a caller obtains for a real target height, resolving the bundle
2423 // version the way the fee and builder paths do: height -> consensus branch ->
2424 // `BundleVersion`. This is the scenario the legacy hardcoded `max(spends, outputs)`
2425 // formula got wrong: at a post-NU6.3 height it understated the Orchard action count
2426 // whenever a step had both spends and outputs, and so understated the ZIP 317 fee.
2427 #[test]
2428 fn orchard_action_count_grows_at_nu6_3_activation_height(
2429 spends in arb_note_count(),
2430 change in arb_note_count(),
2431 note_value in arb_note_value(),
2432 change_value in arb_note_value(),
2433 pre_nu6_3 in arb_pre_nu6_3_upgrade(),
2434 nu6_3_or_later in arb_nu6_3_or_later_upgrade(),
2435 ) {
2436 let orchard_step = step_with_notes_and_change(
2437 orchard_and_ironwood_notes((spends, note_value), (0, 0)),
2438 std::iter::repeat_n(shielded_change(ShieldedPool::Orchard, change_value), change)
2439 .collect(),
2440 );
2441
2442 // Pre-NU6.3, each change output shares an action with a spend: max(spends, change).
2443 prop_assert_eq!(
2444 orchard_step.orchard_action_count(
2445 BundlePadding::UNPADDED,
2446 bundle_version_at(pre_nu6_3, ValuePool::Orchard),
2447 ),
2448 Ok(spends.max(change))
2449 );
2450
2451 // From NU6.3, the Orchard pool disables cross-address transfers: a change output's
2452 // corresponding dummy input must be signed with the spending key for the internal
2453 // IVK the change is sent to, so it cannot share an action with a spend of a note
2454 // belonging to a different address. Hence spends + change actions.
2455 prop_assert_eq!(
2456 orchard_step.orchard_action_count(
2457 BundlePadding::UNPADDED,
2458 bundle_version_at(nu6_3_or_later, ValuePool::Orchard),
2459 ),
2460 Ok(spends + change)
2461 );
2462
2463 // The Ironwood pool retains cross-address transfers, so an equivalent Ironwood step
2464 // still pairs at a post-NU6.3 height.
2465 let ironwood_step = step_with_notes_and_change(
2466 orchard_and_ironwood_notes((0, 0), (spends, note_value)),
2467 std::iter::repeat_n(shielded_change(ShieldedPool::Ironwood, change_value), change)
2468 .collect(),
2469 );
2470 prop_assert_eq!(
2471 ironwood_step.ironwood_action_count(
2472 BundlePadding::UNPADDED,
2473 bundle_version_at(nu6_3_or_later, ValuePool::Ironwood),
2474 ),
2475 Ok(spends.max(change))
2476 );
2477 }
2478
2479 // A non-empty bundle is charged the greater of the actions it requests and the bundle
2480 // type's padding floor: padding never reduces the count, and the floor is never
2481 // undershot. A bundle that requests nothing is not produced at all, so it is charged
2482 // nothing however high the floor. `DEFAULT` (floor 2) and `UNPADDED` (floor 1) are the
2483 // floors the wallet itself uses; the whole `u8` range is covered here, including the
2484 // degenerate zero floor, since the bundle type is the caller's to choose.
2485 #[test]
2486 fn action_count_is_never_below_the_bundle_types_padding_floor(
2487 spends in arb_note_count(),
2488 change in arb_note_count(),
2489 note_value in arb_note_value(),
2490 change_value in arb_note_value(),
2491 pad_to_minimum in 0u8..=u8::MAX,
2492 ) {
2493 let step = step_with_notes_and_change(
2494 orchard_and_ironwood_notes((spends, note_value), (0, 0)),
2495 std::iter::repeat_n(shielded_change(ShieldedPool::Orchard, change_value), change)
2496 .collect(),
2497 );
2498 let bundle_type = BundlePadding {
2499 bundle_required: false,
2500 pad_to_minimum: Some(pad_to_minimum),
2501 };
2502
2503 // Post-NU6.3, so the requested count is `spends + change`.
2504 let requested = spends + change;
2505 let expected = if requested == 0 {
2506 0
2507 } else {
2508 requested.max(usize::from(pad_to_minimum))
2509 };
2510 prop_assert_eq!(
2511 step.orchard_action_count(bundle_type, BundleVersion::orchard_v3()),
2512 Ok(expected)
2513 );
2514 }
2515
2516 // `Note::pool` reports the `ValuePool` recorded alongside an Orchard note.
2517 #[test]
2518 fn note_pool_reports_stored_value_pool(
2519 value in 1u64..1_000_000_000u64,
2520 is_ironwood in any::<bool>(),
2521 ) {
2522 let (version, pool, expected) = if is_ironwood {
2523 (NoteVersion::V3, ValuePool::Ironwood, ShieldedPool::Ironwood)
2524 } else {
2525 (NoteVersion::V2, ValuePool::Orchard, ShieldedPool::Orchard)
2526 };
2527 let Some(note) = orchard_note(value, version) else {
2528 // A handful of (value, rho, rseed) combinations do not form a valid note; skip them.
2529 return Err(TestCaseError::reject("invalid orchard note"));
2530 };
2531 let note = Note::Orchard { note, pool };
2532 prop_assert_eq!(note.pool(), expected);
2533 }
2534
2535 // `Step::input_count_in_pool` returns the number of selected notes in each pool, splitting
2536 // Orchard (version 2) from Ironwood (version 3); Sapling is zero here. `input_in_pool`
2537 // agrees with `input_count_in_pool > 0`, and `Proposal::input_count_in_pool` sums the
2538 // per-step counts.
2539 #[test]
2540 fn step_and_proposal_input_counts_match_constructed_notes(
2541 n_orchard in 0usize..5,
2542 n_ironwood in 0usize..5,
2543 m_orchard in 0usize..5,
2544 m_ironwood in 0usize..5,
2545 ) {
2546 let step1 = step_with_notes(orchard_and_ironwood_notes((n_orchard, 10_000), (n_ironwood, 20_000)));
2547 prop_assert_eq!(step1.input_count_in_pool(PoolType::SAPLING), 0);
2548 prop_assert_eq!(step1.input_count_in_pool(PoolType::ORCHARD), n_orchard);
2549 prop_assert_eq!(step1.input_count_in_pool(PoolType::IRONWOOD), n_ironwood);
2550
2551 for pool in [ShieldedPool::Sapling, ShieldedPool::Orchard, ShieldedPool::Ironwood] {
2552 let pool_type = PoolType::Shielded(pool);
2553 prop_assert_eq!(
2554 step1.input_in_pool(pool_type),
2555 step1.input_count_in_pool(pool_type) > 0
2556 );
2557 }
2558
2559 let step2 = step_with_notes(orchard_and_ironwood_notes((m_orchard, 10_000), (m_ironwood, 20_000)));
2560 let proposal = Proposal::<(), u32> {
2561 fee_rule: (),
2562 min_target_height: TargetHeight::from(100u32),
2563 confirmations_policy: ConfirmationsPolicy::default(),
2564 proposed_version: None,
2565 steps: NonEmpty::from_vec(vec![step1, step2]).unwrap(),
2566 };
2567 prop_assert_eq!(proposal.input_count_in_pool(PoolType::SAPLING), 0);
2568 prop_assert_eq!(
2569 proposal.input_count_in_pool(PoolType::ORCHARD),
2570 n_orchard + m_orchard
2571 );
2572 prop_assert_eq!(
2573 proposal.input_count_in_pool(PoolType::IRONWOOD),
2574 n_ironwood + m_ironwood
2575 );
2576 }
2577 }
2578}