zcash_client_backend/fees/
common.rs

1use core::cmp::{max, min, Ordering};
2use std::num::{NonZeroU64, NonZeroUsize};
3
4use zcash_primitives::transaction::fees::{
5    transparent, zip317::MINIMUM_FEE, zip317::P2PKH_STANDARD_OUTPUT_SIZE, FeeRule,
6};
7use zcash_protocol::{
8    consensus::{self, BlockHeight},
9    memo::MemoBytes,
10    value::{BalanceError, Zatoshis},
11    ShieldedProtocol,
12};
13
14use crate::data_api::AccountMeta;
15
16use super::{
17    sapling as sapling_fees, ChangeError, ChangeValue, DustAction, DustOutputPolicy,
18    EphemeralBalance, SplitPolicy, TransactionBalance,
19};
20
21#[cfg(feature = "orchard")]
22use super::orchard as orchard_fees;
23
24pub(crate) struct NetFlows {
25    t_in: Zatoshis,
26    t_out: Zatoshis,
27    sapling_in: Zatoshis,
28    sapling_out: Zatoshis,
29    orchard_in: Zatoshis,
30    orchard_out: Zatoshis,
31}
32
33impl NetFlows {
34    fn total_in(&self) -> Result<Zatoshis, BalanceError> {
35        (self.t_in + self.sapling_in + self.orchard_in).ok_or(BalanceError::Overflow)
36    }
37    fn total_out(&self) -> Result<Zatoshis, BalanceError> {
38        (self.t_out + self.sapling_out + self.orchard_out).ok_or(BalanceError::Overflow)
39    }
40    /// Returns true iff the flows excluding change are fully transparent.
41    fn is_transparent(&self) -> bool {
42        !(self.sapling_in.is_positive()
43            || self.sapling_out.is_positive()
44            || self.orchard_in.is_positive()
45            || self.orchard_out.is_positive())
46    }
47}
48
49#[allow(clippy::too_many_arguments)]
50pub(crate) fn calculate_net_flows<NoteRefT: Clone, F: FeeRule, E>(
51    transparent_inputs: &[impl transparent::InputView],
52    transparent_outputs: &[impl transparent::OutputView],
53    sapling: &impl sapling_fees::BundleView<NoteRefT>,
54    #[cfg(feature = "orchard")] orchard: &impl orchard_fees::BundleView<NoteRefT>,
55    ephemeral_balance: Option<&EphemeralBalance>,
56) -> Result<NetFlows, ChangeError<E, NoteRefT>>
57where
58    E: From<F::Error> + From<BalanceError>,
59{
60    let overflow = || ChangeError::StrategyError(E::from(BalanceError::Overflow));
61
62    let t_in = transparent_inputs
63        .iter()
64        .map(|t_in| t_in.coin().value)
65        .chain(ephemeral_balance.and_then(|b| b.ephemeral_input_amount()))
66        .sum::<Option<_>>()
67        .ok_or_else(overflow)?;
68    let t_out = transparent_outputs
69        .iter()
70        .map(|t_out| t_out.value())
71        .chain(ephemeral_balance.and_then(|b| b.ephemeral_output_amount()))
72        .sum::<Option<_>>()
73        .ok_or_else(overflow)?;
74    let sapling_in = sapling
75        .inputs()
76        .iter()
77        .map(sapling_fees::InputView::<NoteRefT>::value)
78        .sum::<Option<_>>()
79        .ok_or_else(overflow)?;
80    let sapling_out = sapling
81        .outputs()
82        .iter()
83        .map(sapling_fees::OutputView::value)
84        .sum::<Option<_>>()
85        .ok_or_else(overflow)?;
86
87    #[cfg(feature = "orchard")]
88    let orchard_in = orchard
89        .inputs()
90        .iter()
91        .map(orchard_fees::InputView::<NoteRefT>::value)
92        .sum::<Option<_>>()
93        .ok_or_else(overflow)?;
94    #[cfg(not(feature = "orchard"))]
95    let orchard_in = Zatoshis::ZERO;
96
97    #[cfg(feature = "orchard")]
98    let orchard_out = orchard
99        .outputs()
100        .iter()
101        .map(orchard_fees::OutputView::value)
102        .sum::<Option<_>>()
103        .ok_or_else(overflow)?;
104    #[cfg(not(feature = "orchard"))]
105    let orchard_out = Zatoshis::ZERO;
106
107    Ok(NetFlows {
108        t_in,
109        t_out,
110        sapling_in,
111        sapling_out,
112        orchard_in,
113        orchard_out,
114    })
115}
116
117/// Decide which shielded pool change should go to if there is any.
118pub(crate) fn select_change_pool(
119    _net_flows: &NetFlows,
120    _fallback_change_pool: ShieldedProtocol,
121) -> ShieldedProtocol {
122    // TODO: implement a less naive strategy for selecting the pool to which change will be sent.
123    #[cfg(feature = "orchard")]
124    if _net_flows.orchard_in.is_positive() || _net_flows.orchard_out.is_positive() {
125        // Send change to Orchard if we're spending any Orchard inputs or creating any Orchard outputs.
126        ShieldedProtocol::Orchard
127    } else if _net_flows.sapling_in.is_positive() || _net_flows.sapling_out.is_positive() {
128        // Otherwise, send change to Sapling if we're spending any Sapling inputs or creating any
129        // Sapling outputs, so that we avoid pool-crossing.
130        ShieldedProtocol::Sapling
131    } else {
132        // The flows are transparent, so there may not be change. If there is, the caller
133        // gets to decide where to shield it.
134        _fallback_change_pool
135    }
136    #[cfg(not(feature = "orchard"))]
137    ShieldedProtocol::Sapling
138}
139
140#[derive(Clone, Copy, Debug)]
141pub(crate) struct OutputManifest {
142    transparent: usize,
143    sapling: usize,
144    orchard: usize,
145}
146
147impl OutputManifest {
148    const ZERO: OutputManifest = OutputManifest {
149        transparent: 0,
150        sapling: 0,
151        orchard: 0,
152    };
153
154    pub(crate) fn sapling(&self) -> usize {
155        self.sapling
156    }
157
158    pub(crate) fn orchard(&self) -> usize {
159        self.orchard
160    }
161
162    pub(crate) fn total_shielded(&self) -> usize {
163        self.sapling + self.orchard
164    }
165}
166
167pub(crate) struct SinglePoolBalanceConfig<'a, P, F> {
168    params: &'a P,
169    fee_rule: &'a F,
170    dust_output_policy: &'a DustOutputPolicy,
171    default_dust_threshold: Zatoshis,
172    split_policy: &'a SplitPolicy,
173    fallback_change_pool: ShieldedProtocol,
174    marginal_fee: Zatoshis,
175    grace_actions: usize,
176}
177
178impl<'a, P, F> SinglePoolBalanceConfig<'a, P, F> {
179    #[allow(clippy::too_many_arguments)]
180    pub(crate) fn new(
181        params: &'a P,
182        fee_rule: &'a F,
183        dust_output_policy: &'a DustOutputPolicy,
184        default_dust_threshold: Zatoshis,
185        split_policy: &'a SplitPolicy,
186        fallback_change_pool: ShieldedProtocol,
187        marginal_fee: Zatoshis,
188        grace_actions: usize,
189    ) -> Self {
190        Self {
191            params,
192            fee_rule,
193            dust_output_policy,
194            default_dust_threshold,
195            split_policy,
196            fallback_change_pool,
197            marginal_fee,
198            grace_actions,
199        }
200    }
201}
202
203#[allow(clippy::too_many_arguments)]
204pub(crate) fn single_pool_output_balance<P: consensus::Parameters, NoteRefT: Clone, F: FeeRule, E>(
205    cfg: SinglePoolBalanceConfig<P, F>,
206    wallet_meta: Option<&AccountMeta>,
207    target_height: BlockHeight,
208    transparent_inputs: &[impl transparent::InputView],
209    transparent_outputs: &[impl transparent::OutputView],
210    sapling: &impl sapling_fees::BundleView<NoteRefT>,
211    #[cfg(feature = "orchard")] orchard: &impl orchard_fees::BundleView<NoteRefT>,
212    change_memo: Option<&MemoBytes>,
213    ephemeral_balance: Option<&EphemeralBalance>,
214) -> Result<TransactionBalance, ChangeError<E, NoteRefT>>
215where
216    E: From<F::Error> + From<BalanceError>,
217{
218    // The change memo, if any, must be attached to the change in the intermediate step that
219    // produces the ephemeral output, and so it should be discarded in the ultimate step; this is
220    // distinguished by identifying that this transaction has ephemeral inputs.
221    let change_memo = change_memo.filter(|_| ephemeral_balance.map_or(true, |b| !b.is_input()));
222
223    let overflow = || ChangeError::StrategyError(E::from(BalanceError::Overflow));
224    let underflow = || ChangeError::StrategyError(E::from(BalanceError::Underflow));
225
226    let net_flows = calculate_net_flows::<NoteRefT, F, E>(
227        transparent_inputs,
228        transparent_outputs,
229        sapling,
230        #[cfg(feature = "orchard")]
231        orchard,
232        ephemeral_balance,
233    )?;
234
235    let change_pool = select_change_pool(&net_flows, cfg.fallback_change_pool);
236    let target_change_count = wallet_meta.map_or(1, |m| {
237        usize::from(cfg.split_policy.target_output_count)
238            // If we cannot determine a total note count, fall back to a single output
239            .saturating_sub(m.total_note_count().unwrap_or(usize::MAX))
240            .max(1)
241    });
242    let target_change_counts = OutputManifest {
243        transparent: 0,
244        sapling: if change_pool == ShieldedProtocol::Sapling {
245            target_change_count
246        } else {
247            0
248        },
249        orchard: if change_pool == ShieldedProtocol::Orchard {
250            target_change_count
251        } else {
252            0
253        },
254    };
255    assert!(target_change_counts.total_shielded() == target_change_count);
256
257    // We don't create a fully-transparent transaction if a change memo is used.
258    let fully_transparent = net_flows.is_transparent() && change_memo.is_none();
259
260    // If we have a non-zero marginal fee, we need to check for uneconomic inputs.
261    // This is basically assuming that fee rules with non-zero marginal fee are
262    // "ZIP 317-like", but we can generalize later if needed.
263    if cfg.marginal_fee.is_positive() {
264        // Is it certain that there will be a change output? If it is not certain,
265        // we should call `check_for_uneconomic_inputs` with `possible_change`
266        // including both possibilities.
267        let possible_change = {
268            // These are the situations where we might not have a change output.
269            if fully_transparent
270                || (cfg.dust_output_policy.action() == DustAction::AddDustToFee
271                    && change_memo.is_none())
272            {
273                vec![OutputManifest::ZERO, target_change_counts]
274            } else {
275                vec![target_change_counts]
276            }
277        };
278
279        check_for_uneconomic_inputs(
280            transparent_inputs,
281            transparent_outputs,
282            sapling,
283            #[cfg(feature = "orchard")]
284            orchard,
285            cfg.marginal_fee,
286            cfg.grace_actions,
287            &possible_change[..],
288            ephemeral_balance,
289        )?;
290    }
291
292    let total_in = net_flows
293        .total_in()
294        .map_err(|e| ChangeError::StrategyError(E::from(e)))?;
295    let subtotal_out = net_flows
296        .total_out()
297        .map_err(|e| ChangeError::StrategyError(E::from(e)))?;
298
299    let sapling_input_count = sapling
300        .bundle_type()
301        .num_spends(sapling.inputs().len())
302        .map_err(ChangeError::BundleError)?;
303    let sapling_output_count = |change_count| {
304        sapling
305            .bundle_type()
306            .num_outputs(
307                sapling.inputs().len(),
308                sapling.outputs().len() + change_count,
309            )
310            .map_err(ChangeError::BundleError)
311    };
312
313    #[cfg(feature = "orchard")]
314    let orchard_action_count = |change_count| {
315        orchard
316            .bundle_type()
317            .num_actions(
318                orchard.inputs().len(),
319                orchard.outputs().len() + change_count,
320            )
321            .map_err(ChangeError::BundleError)
322    };
323    #[cfg(not(feature = "orchard"))]
324    let orchard_action_count = |change_count: usize| -> Result<usize, ChangeError<E, NoteRefT>> {
325        if change_count != 0 {
326            Err(ChangeError::BundleError(
327                "Nonzero Orchard change requested but the `orchard` feature is not enabled.",
328            ))
329        } else {
330            Ok(0)
331        }
332    };
333
334    let transparent_input_sizes = transparent_inputs
335        .iter()
336        .map(|i| i.serialized_size())
337        .chain(
338            ephemeral_balance
339                .and_then(|b| b.ephemeral_input_amount())
340                .map(|_| transparent::InputSize::STANDARD_P2PKH),
341        );
342    let transparent_output_sizes = transparent_outputs
343        .iter()
344        .map(|i| i.serialized_size())
345        .chain(
346            ephemeral_balance
347                .and_then(|b| b.ephemeral_output_amount())
348                .map(|_| P2PKH_STANDARD_OUTPUT_SIZE),
349        );
350
351    // Once we calculate the balance with minimum fee (i.e. with no change),
352    // there are three cases:
353    //
354    // 1. Insufficient funds even with minimum fee.
355    // 2. The minimum fee exactly cancels out the net flow balance.
356    // 3. The minimum fee is smaller than the change.
357    //
358    // If case 2 happens for a transaction with any shielded flows, we want there
359    // to be a zero-value shielded change output anyway (i.e. treat this like case 3),
360    // because:
361    // * being able to distinguish these cases potentially leaks too much
362    //   information (an adversary that knows the number of external recipients
363    //   and the sum of their outputs learns the sum of the inputs if no change
364    //   output is present); and
365    // * we will then always have an shielded output in which to put change_memo,
366    //   if one is used.
367    //
368    // Note that using the `DustAction::AddDustToFee` policy inherently leaks
369    // more information.
370
371    let min_fee = cfg
372        .fee_rule
373        .fee_required(
374            cfg.params,
375            target_height,
376            transparent_input_sizes.clone(),
377            transparent_output_sizes.clone(),
378            sapling_input_count,
379            sapling_output_count(0)?,
380            orchard_action_count(0)?,
381        )
382        .map_err(|fee_error| ChangeError::StrategyError(E::from(fee_error)))?;
383
384    let total_out_with_min_fee = (subtotal_out + min_fee).ok_or_else(overflow)?;
385
386    #[allow(unused_mut)]
387    let (mut change, fee) = match total_in.cmp(&total_out_with_min_fee) {
388        Ordering::Less => {
389            // Case 1. Insufficient input value exists to pay the minimum fee; there's no way
390            // we can construct the transaction.
391            return Err(ChangeError::InsufficientFunds {
392                available: total_in,
393                required: total_out_with_min_fee,
394            });
395        }
396        Ordering::Equal if fully_transparent => {
397            // Case 2 for a tx with all transparent flows and no change memo
398            // (e.g. the second transaction of a ZIP 320 pair).
399            (vec![], min_fee)
400        }
401        _ => {
402            let max_fee = max(
403                min_fee,
404                cfg.fee_rule
405                    .fee_required(
406                        cfg.params,
407                        target_height,
408                        transparent_input_sizes.clone(),
409                        transparent_output_sizes.clone(),
410                        sapling_input_count,
411                        sapling_output_count(target_change_counts.sapling())?,
412                        orchard_action_count(target_change_counts.orchard())?,
413                    )
414                    .map_err(|fee_error| ChangeError::StrategyError(E::from(fee_error)))?,
415            );
416
417            let total_out_with_max_fee = (subtotal_out + max_fee).ok_or_else(overflow)?;
418
419            // We obtain a split count based on the total number of notes of sufficient size
420            // available in the wallet, irrespective of pool. If we don't have any wallet metadata
421            // available, we fall back to generating a single change output.
422            let split_count = usize::from(wallet_meta.map_or(NonZeroUsize::MIN, |wm| {
423                cfg.split_policy.split_count(
424                    wm.total_note_count(),
425                    wm.total_value(),
426                    // We use a saturating subtraction here because there may be insufficient funds to pay
427                    // the fee, *if* the requested number of split outputs are created. If there is no
428                    // proposed change, the split policy should recommend only a single change output.
429                    (total_in - total_out_with_max_fee).unwrap_or(Zatoshis::ZERO),
430                )
431            }));
432
433            // If we don't have as many change outputs as we expected, recompute the fee.
434            let total_fee = if split_count < target_change_count {
435                cfg.fee_rule
436                    .fee_required(
437                        cfg.params,
438                        target_height,
439                        transparent_input_sizes,
440                        transparent_output_sizes,
441                        sapling_input_count,
442                        sapling_output_count(if change_pool == ShieldedProtocol::Sapling {
443                            split_count
444                        } else {
445                            0
446                        })?,
447                        orchard_action_count(if change_pool == ShieldedProtocol::Orchard {
448                            split_count
449                        } else {
450                            0
451                        })?,
452                    )
453                    .map_err(|fee_error| ChangeError::StrategyError(E::from(fee_error)))?
454            } else {
455                max_fee
456            };
457
458            let total_out = (subtotal_out + total_fee).ok_or_else(overflow)?;
459            let total_change =
460                (total_in - total_out).ok_or_else(|| ChangeError::InsufficientFunds {
461                    available: total_in,
462                    required: total_out,
463                })?;
464
465            let per_output_change = total_change.div_with_remainder(
466                NonZeroU64::new(u64::try_from(split_count).expect("usize fits into u64")).unwrap(),
467            );
468            let simple_case = || {
469                (
470                    (0usize..split_count)
471                        .map(|i| {
472                            ChangeValue::shielded(
473                                change_pool,
474                                if i == 0 {
475                                    // Add any remainder to the first output only
476                                    (*per_output_change.quotient() + *per_output_change.remainder())
477                                        .unwrap()
478                                } else {
479                                    // For any other output, the change value will just be the
480                                    // quotient.
481                                    *per_output_change.quotient()
482                                },
483                                change_memo.cloned(),
484                            )
485                        })
486                        .collect(),
487                    total_fee,
488                )
489            };
490
491            let change_dust_threshold = cfg
492                .dust_output_policy
493                .dust_threshold()
494                .unwrap_or(cfg.default_dust_threshold);
495
496            if total_change < change_dust_threshold {
497                match cfg.dust_output_policy.action() {
498                    DustAction::Reject => {
499                        // Always allow zero-valued change even for the `Reject` policy:
500                        // * it should be allowed in order to record change memos and to improve
501                        //   indistinguishability;
502                        // * this case occurs in practice when sending all funds from an account;
503                        // * zero-valued notes do not require witness tracking;
504                        // * the effect on trial decryption overhead is small.
505                        if total_change.is_zero() {
506                            simple_case()
507                        } else {
508                            let shortfall =
509                                (change_dust_threshold - total_change).ok_or_else(underflow)?;
510
511                            return Err(ChangeError::InsufficientFunds {
512                                available: total_in,
513                                required: (total_in + shortfall).ok_or_else(overflow)?,
514                            });
515                        }
516                    }
517                    DustAction::AllowDustChange => simple_case(),
518                    DustAction::AddDustToFee => {
519                        // Zero-valued change is also always allowed for this policy, but when
520                        // no change memo is given, we might omit the change output instead.
521                        let fee_with_dust = (total_change + total_fee).ok_or_else(overflow)?;
522
523                        let reasonable_fee =
524                            (total_fee + (MINIMUM_FEE * 10u64).unwrap()).ok_or_else(overflow)?;
525
526                        if fee_with_dust > reasonable_fee {
527                            // Defend against losing money by using AddDustToFee with a too-high
528                            // dust threshold.
529                            simple_case()
530                        } else if change_memo.is_some() {
531                            (
532                                vec![ChangeValue::shielded(
533                                    change_pool,
534                                    Zatoshis::ZERO,
535                                    change_memo.cloned(),
536                                )],
537                                fee_with_dust,
538                            )
539                        } else {
540                            (vec![], fee_with_dust)
541                        }
542                    }
543                }
544            } else {
545                simple_case()
546            }
547        }
548    };
549
550    #[cfg(feature = "transparent-inputs")]
551    change.extend(
552        ephemeral_balance
553            .and_then(|b| b.ephemeral_output_amount())
554            .map(ChangeValue::ephemeral_transparent),
555    );
556
557    TransactionBalance::new(change, fee).map_err(|_| overflow())
558}
559
560/// Returns a `[ChangeStrategy::DustInputs]` error if some of the inputs provided
561/// to the transaction have value less than the marginal fee, and could not be
562/// determined to have any economic value in the context of this input selection.
563///
564/// This determination is potentially conservative in the sense that outputs
565/// with value less than the marginal fee might be excluded, even though in
566/// practice they would not cause the fee to increase. Outputs with value
567/// greater than the marginal fee will never be excluded.
568///
569/// `possible_change` is a slice of `(transparent_change, sapling_change, orchard_change)`
570/// tuples indicating possible combinations of how many change outputs (0 or 1)
571/// might be included in the transaction for each pool. The shape of the tuple
572/// does not depend on which protocol features are enabled.
573#[allow(clippy::too_many_arguments)]
574pub(crate) fn check_for_uneconomic_inputs<NoteRefT: Clone, E>(
575    transparent_inputs: &[impl transparent::InputView],
576    transparent_outputs: &[impl transparent::OutputView],
577    sapling: &impl sapling_fees::BundleView<NoteRefT>,
578    #[cfg(feature = "orchard")] orchard: &impl orchard_fees::BundleView<NoteRefT>,
579    marginal_fee: Zatoshis,
580    grace_actions: usize,
581    possible_change: &[OutputManifest],
582    ephemeral_balance: Option<&EphemeralBalance>,
583) -> Result<(), ChangeError<E, NoteRefT>> {
584    let mut t_dust: Vec<_> = transparent_inputs
585        .iter()
586        .filter_map(|i| {
587            // For now, we're just assuming P2PKH inputs, so we don't check the
588            // size of the input script.
589            if i.coin().value <= marginal_fee {
590                Some(i.outpoint().clone())
591            } else {
592                None
593            }
594        })
595        .collect();
596
597    let mut s_dust: Vec<_> = sapling
598        .inputs()
599        .iter()
600        .filter_map(|i| {
601            if sapling_fees::InputView::<NoteRefT>::value(i) <= marginal_fee {
602                Some(sapling_fees::InputView::<NoteRefT>::note_id(i).clone())
603            } else {
604                None
605            }
606        })
607        .collect();
608
609    #[cfg(feature = "orchard")]
610    let mut o_dust: Vec<NoteRefT> = orchard
611        .inputs()
612        .iter()
613        .filter_map(|i| {
614            if orchard_fees::InputView::<NoteRefT>::value(i) <= marginal_fee {
615                Some(orchard_fees::InputView::<NoteRefT>::note_id(i).clone())
616            } else {
617                None
618            }
619        })
620        .collect();
621    #[cfg(not(feature = "orchard"))]
622    let mut o_dust: Vec<NoteRefT> = vec![];
623
624    // If we don't have any dust inputs, there is nothing to check.
625    if t_dust.is_empty() && s_dust.is_empty() && o_dust.is_empty() {
626        return Ok(());
627    }
628
629    let (t_inputs_len, t_outputs_len) = (
630        transparent_inputs.len() + usize::from(ephemeral_balance.is_some_and(|b| b.is_input())),
631        transparent_outputs.len() + usize::from(ephemeral_balance.is_some_and(|b| b.is_output())),
632    );
633    let (s_inputs_len, s_outputs_len) = (sapling.inputs().len(), sapling.outputs().len());
634    #[cfg(feature = "orchard")]
635    let (o_inputs_len, o_outputs_len) = (orchard.inputs().len(), orchard.outputs().len());
636    #[cfg(not(feature = "orchard"))]
637    let (o_inputs_len, o_outputs_len) = (0usize, 0usize);
638
639    let t_non_dust = t_inputs_len.checked_sub(t_dust.len()).unwrap();
640    let s_non_dust = s_inputs_len.checked_sub(s_dust.len()).unwrap();
641    let o_non_dust = o_inputs_len.checked_sub(o_dust.len()).unwrap();
642
643    // Return the number of allowed dust inputs from each pool.
644    let allowed_dust = |change: &OutputManifest| {
645        // Here we assume a "ZIP 317-like" fee model in which the existence of an output
646        // to a given pool implies that a corresponding input from that pool can be
647        // provided without increasing the fee. (This is also likely to be true for
648        // future fee models if we do not want to penalize use of Orchard relative to
649        // other pools.)
650        //
651        // Under that assumption, we want to calculate the maximum number of dust inputs
652        // from each pool, out of the ones we actually have, that can be economically
653        // spent along with the non-dust inputs. Get an initial estimate by calculating
654        // the number of dust inputs in each pool that will be allowed regardless of
655        // padding or grace.
656
657        let t_allowed = min(
658            t_dust.len(),
659            (t_outputs_len + change.transparent).saturating_sub(t_non_dust),
660        );
661        let s_allowed = min(
662            s_dust.len(),
663            (s_outputs_len + change.sapling).saturating_sub(s_non_dust),
664        );
665        let o_allowed = min(
666            o_dust.len(),
667            (o_outputs_len + change.orchard).saturating_sub(o_non_dust),
668        );
669
670        // We'll be spending the non-dust and allowed dust in each pool.
671        let t_req_inputs = t_non_dust + t_allowed;
672        let s_req_inputs = s_non_dust + s_allowed;
673        #[cfg(feature = "orchard")]
674        let o_req_inputs = o_non_dust + o_allowed;
675
676        // This calculates the hypothetical number of actions with given extra inputs,
677        // for ZIP 317 and the padding rules in effect. The padding rules for each
678        // pool are subtle (they also depend on `bundle_required` for example), so we
679        // must actually call them rather than try to predict their effect. To tell
680        // whether we can freely add an extra input from a given pool, we need to call
681        // them both with and without that input; if the number of actions does not
682        // increase, then the input is free to add.
683        let hypothetical_actions = |t_extra, s_extra, _o_extra| {
684            let s_spend_count = sapling
685                .bundle_type()
686                .num_spends(s_req_inputs + s_extra)
687                .map_err(ChangeError::BundleError)?;
688
689            let s_output_count = sapling
690                .bundle_type()
691                .num_outputs(s_req_inputs + s_extra, s_outputs_len + change.sapling)
692                .map_err(ChangeError::BundleError)?;
693
694            #[cfg(feature = "orchard")]
695            let o_action_count = orchard
696                .bundle_type()
697                .num_actions(o_req_inputs + _o_extra, o_outputs_len + change.orchard)
698                .map_err(ChangeError::BundleError)?;
699            #[cfg(not(feature = "orchard"))]
700            let o_action_count = 0;
701
702            // To calculate the number of unused actions, we assume that transparent inputs
703            // and outputs are P2PKH.
704            Ok(
705                max(t_req_inputs + t_extra, t_outputs_len + change.transparent)
706                    + max(s_spend_count, s_output_count)
707                    + o_action_count,
708            )
709        };
710
711        // First calculate the baseline number of logical actions with only the definitely
712        // allowed inputs estimated above. If it is less than `grace_actions`, try to allocate
713        // a grace input first to transparent dust, then to Sapling dust, then to Orchard dust.
714        // If the number of actions increases, it was not possible to allocate that input for
715        // free. This approach is sufficient because at most one such input can be allocated,
716        // since `grace_actions` is at most 2 for ZIP 317 and there must be at least one
717        // logical action. (If `grace_actions` were greater than 2 then the code would still
718        // be correct, it would just not find all potential extra inputs.)
719
720        let baseline = hypothetical_actions(0, 0, 0)?;
721
722        let (t_extra, s_extra, o_extra) = if baseline >= grace_actions {
723            (0, 0, 0)
724        } else if t_dust.len() > t_allowed && hypothetical_actions(1, 0, 0)? <= baseline {
725            (1, 0, 0)
726        } else if s_dust.len() > s_allowed && hypothetical_actions(0, 1, 0)? <= baseline {
727            (0, 1, 0)
728        } else if o_dust.len() > o_allowed && hypothetical_actions(0, 0, 1)? <= baseline {
729            (0, 0, 1)
730        } else {
731            (0, 0, 0)
732        };
733        Ok(OutputManifest {
734            transparent: t_allowed + t_extra,
735            sapling: s_allowed + s_extra,
736            orchard: o_allowed + o_extra,
737        })
738    };
739
740    // Find the least number of allowed dust inputs for each pool for any `possible_change`.
741    let allowed = possible_change
742        .iter()
743        .map(allowed_dust)
744        .collect::<Result<Vec<_>, _>>()?
745        .into_iter()
746        .reduce(|l, r| OutputManifest {
747            transparent: min(l.transparent, r.transparent),
748            sapling: min(l.sapling, r.sapling),
749            orchard: min(l.orchard, r.orchard),
750        })
751        .expect("possible_change is nonempty");
752
753    // The inputs in the tail of each list after the first `*_allowed` are returned as uneconomic.
754    // The caller should order the inputs from most to least preferred to spend.
755    let t_dust = t_dust.split_off(allowed.transparent);
756    let s_dust = s_dust.split_off(allowed.sapling);
757    let o_dust = o_dust.split_off(allowed.orchard);
758
759    if t_dust.is_empty() && s_dust.is_empty() && o_dust.is_empty() {
760        Ok(())
761    } else {
762        Err(ChangeError::DustInputs {
763            transparent: t_dust,
764            sapling: s_dust,
765            #[cfg(feature = "orchard")]
766            orchard: o_dust,
767        })
768    }
769}