zcash_client_backend/fees/
zip317.rs

1//! Change strategies designed to implement the ZIP 317 fee rules.
2//!
3//! Change selection in ZIP 317 requires careful handling of low-valued inputs
4//! to ensure that inputs added to a transaction do not cause fees to rise by
5//! an amount greater than their value.
6
7use core::marker::PhantomData;
8
9use zcash_primitives::transaction::fees::{transparent, zip317 as prim_zip317, FeeRule};
10use zcash_protocol::{
11    consensus::{self, BlockHeight},
12    memo::MemoBytes,
13    value::{BalanceError, Zatoshis},
14    ShieldedProtocol,
15};
16
17use crate::{
18    data_api::{AccountMeta, InputSource, NoteFilter},
19    fees::StandardFeeRule,
20};
21
22use super::{
23    common::{single_pool_output_balance, SinglePoolBalanceConfig},
24    sapling as sapling_fees, ChangeError, ChangeStrategy, DustOutputPolicy, EphemeralBalance,
25    SplitPolicy, TransactionBalance,
26};
27
28#[cfg(feature = "orchard")]
29use super::orchard as orchard_fees;
30
31/// An extension to the [`FeeRule`] trait that exposes methods required for
32/// ZIP 317 fee calculation.
33pub trait Zip317FeeRule: FeeRule {
34    /// Returns the ZIP 317 marginal fee.
35    fn marginal_fee(&self) -> Zatoshis;
36
37    /// Returns the ZIP 317 number of grace actions
38    fn grace_actions(&self) -> usize;
39}
40
41impl Zip317FeeRule for prim_zip317::FeeRule {
42    fn marginal_fee(&self) -> Zatoshis {
43        self.marginal_fee()
44    }
45
46    fn grace_actions(&self) -> usize {
47        self.grace_actions()
48    }
49}
50
51impl Zip317FeeRule for StandardFeeRule {
52    fn marginal_fee(&self) -> Zatoshis {
53        prim_zip317::FeeRule::standard().marginal_fee()
54    }
55
56    fn grace_actions(&self) -> usize {
57        prim_zip317::FeeRule::standard().grace_actions()
58    }
59}
60
61/// A change strategy that proposes change as a single output. The output pool is chosen
62/// as the most current pool that avoids unnecessary pool-crossing (with a specified
63/// fallback when the transaction has no shielded inputs). Fee calculation is delegated
64/// to the provided fee rule.
65pub struct SingleOutputChangeStrategy<R, I> {
66    fee_rule: R,
67    change_memo: Option<MemoBytes>,
68    fallback_change_pool: ShieldedProtocol,
69    dust_output_policy: DustOutputPolicy,
70    meta_source: PhantomData<I>,
71}
72
73impl<R, I> SingleOutputChangeStrategy<R, I> {
74    /// Constructs a new [`SingleOutputChangeStrategy`] with the specified ZIP 317
75    /// fee parameters and change memo.
76    ///
77    /// `fallback_change_pool` is used when more than one shielded pool is enabled via
78    /// feature flags, and the transaction has no shielded inputs.
79    pub fn new(
80        fee_rule: R,
81        change_memo: Option<MemoBytes>,
82        fallback_change_pool: ShieldedProtocol,
83        dust_output_policy: DustOutputPolicy,
84    ) -> Self {
85        Self {
86            fee_rule,
87            change_memo,
88            fallback_change_pool,
89            dust_output_policy,
90            meta_source: PhantomData,
91        }
92    }
93}
94
95impl<R, I> ChangeStrategy for SingleOutputChangeStrategy<R, I>
96where
97    R: Zip317FeeRule + Clone,
98    I: InputSource,
99    <R as FeeRule>::Error: From<BalanceError>,
100{
101    type FeeRule = R;
102    type Error = <R as FeeRule>::Error;
103    type MetaSource = I;
104    type AccountMetaT = ();
105
106    fn fee_rule(&self) -> &Self::FeeRule {
107        &self.fee_rule
108    }
109
110    fn fetch_wallet_meta(
111        &self,
112        _meta_source: &Self::MetaSource,
113        _account: <Self::MetaSource as InputSource>::AccountId,
114        _exclude: &[<Self::MetaSource as InputSource>::NoteRef],
115    ) -> Result<Self::AccountMetaT, <Self::MetaSource as InputSource>::Error> {
116        Ok(())
117    }
118
119    fn compute_balance<P: consensus::Parameters, NoteRefT: Clone>(
120        &self,
121        params: &P,
122        target_height: BlockHeight,
123        transparent_inputs: &[impl transparent::InputView],
124        transparent_outputs: &[impl transparent::OutputView],
125        sapling: &impl sapling_fees::BundleView<NoteRefT>,
126        #[cfg(feature = "orchard")] orchard: &impl orchard_fees::BundleView<NoteRefT>,
127        ephemeral_balance: Option<&EphemeralBalance>,
128        _wallet_meta: &Self::AccountMetaT,
129    ) -> Result<TransactionBalance, ChangeError<Self::Error, NoteRefT>> {
130        let split_policy = SplitPolicy::single_output();
131        let cfg = SinglePoolBalanceConfig::new(
132            params,
133            &self.fee_rule,
134            &self.dust_output_policy,
135            self.fee_rule.marginal_fee(),
136            &split_policy,
137            self.fallback_change_pool,
138            self.fee_rule.marginal_fee(),
139            self.fee_rule.grace_actions(),
140        );
141
142        single_pool_output_balance(
143            cfg,
144            None,
145            target_height,
146            transparent_inputs,
147            transparent_outputs,
148            sapling,
149            #[cfg(feature = "orchard")]
150            orchard,
151            self.change_memo.as_ref(),
152            ephemeral_balance,
153        )
154    }
155}
156
157/// A change strategy that attempts to split the change value into some number of equal-sized notes
158/// as dictated by the included [`SplitPolicy`] value.
159pub struct MultiOutputChangeStrategy<R, I> {
160    fee_rule: R,
161    change_memo: Option<MemoBytes>,
162    fallback_change_pool: ShieldedProtocol,
163    dust_output_policy: DustOutputPolicy,
164    split_policy: SplitPolicy,
165    meta_source: PhantomData<I>,
166}
167
168impl<R, I> MultiOutputChangeStrategy<R, I> {
169    /// Constructs a new [`MultiOutputChangeStrategy`] with the specified ZIP 317
170    /// fee parameters, change memo, and change splitting policy.
171    ///
172    /// This change strategy will fall back to creating a single change output if insufficient
173    /// change value is available to create notes with at least the minimum value dictated by the
174    /// split policy.
175    ///
176    /// - `fallback_change_pool`: the pool to which change will be sent if when more than one
177    ///   shielded pool is enabled via feature flags, and the transaction has no shielded inputs.
178    /// - `split_policy`: A policy value describing how the change value should be returned as
179    ///   multiple notes.
180    pub fn new(
181        fee_rule: R,
182        change_memo: Option<MemoBytes>,
183        fallback_change_pool: ShieldedProtocol,
184        dust_output_policy: DustOutputPolicy,
185        split_policy: SplitPolicy,
186    ) -> Self {
187        Self {
188            fee_rule,
189            change_memo,
190            fallback_change_pool,
191            dust_output_policy,
192            split_policy,
193            meta_source: PhantomData,
194        }
195    }
196}
197
198impl<R, I> ChangeStrategy for MultiOutputChangeStrategy<R, I>
199where
200    R: Zip317FeeRule + Clone,
201    I: InputSource,
202    <R as FeeRule>::Error: From<BalanceError>,
203{
204    type FeeRule = R;
205    type Error = <R as FeeRule>::Error;
206    type MetaSource = I;
207    type AccountMetaT = AccountMeta;
208
209    fn fee_rule(&self) -> &Self::FeeRule {
210        &self.fee_rule
211    }
212
213    fn fetch_wallet_meta(
214        &self,
215        meta_source: &Self::MetaSource,
216        account: <Self::MetaSource as InputSource>::AccountId,
217        exclude: &[<Self::MetaSource as InputSource>::NoteRef],
218    ) -> Result<Self::AccountMetaT, <Self::MetaSource as InputSource>::Error> {
219        let note_selector = NoteFilter::ExceedsMinValue(
220            self.split_policy
221                .min_split_output_value()
222                .unwrap_or(SplitPolicy::MIN_NOTE_VALUE),
223        );
224
225        meta_source.get_account_metadata(account, &note_selector, exclude)
226    }
227
228    fn compute_balance<P: consensus::Parameters, NoteRefT: Clone>(
229        &self,
230        params: &P,
231        target_height: BlockHeight,
232        transparent_inputs: &[impl transparent::InputView],
233        transparent_outputs: &[impl transparent::OutputView],
234        sapling: &impl sapling_fees::BundleView<NoteRefT>,
235        #[cfg(feature = "orchard")] orchard: &impl orchard_fees::BundleView<NoteRefT>,
236        ephemeral_balance: Option<&EphemeralBalance>,
237        wallet_meta: &Self::AccountMetaT,
238    ) -> Result<TransactionBalance, ChangeError<Self::Error, NoteRefT>> {
239        let cfg = SinglePoolBalanceConfig::new(
240            params,
241            &self.fee_rule,
242            &self.dust_output_policy,
243            self.fee_rule.marginal_fee(),
244            &self.split_policy,
245            self.fallback_change_pool,
246            self.fee_rule.marginal_fee(),
247            self.fee_rule.grace_actions(),
248        );
249
250        single_pool_output_balance(
251            cfg,
252            Some(wallet_meta),
253            target_height,
254            transparent_inputs,
255            transparent_outputs,
256            sapling,
257            #[cfg(feature = "orchard")]
258            orchard,
259            self.change_memo.as_ref(),
260            ephemeral_balance,
261        )
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use core::{convert::Infallible, num::NonZeroUsize};
268
269    use ::transparent::{address::Script, bundle::TxOut};
270    use zcash_primitives::transaction::fees::zip317::FeeRule as Zip317FeeRule;
271    use zcash_protocol::{
272        consensus::{Network, NetworkUpgrade, Parameters},
273        value::Zatoshis,
274        ShieldedProtocol,
275    };
276
277    use super::SingleOutputChangeStrategy;
278    use crate::{
279        data_api::{
280            testing::MockWalletDb, wallet::input_selection::SaplingPayment, AccountMeta, PoolMeta,
281        },
282        fees::{
283            tests::{TestSaplingInput, TestTransparentInput},
284            zip317::MultiOutputChangeStrategy,
285            ChangeError, ChangeStrategy, ChangeValue, DustAction, DustOutputPolicy, SplitPolicy,
286        },
287    };
288
289    #[cfg(feature = "orchard")]
290    use {
291        crate::data_api::wallet::input_selection::OrchardPayment,
292        crate::fees::orchard as orchard_fees,
293    };
294
295    #[test]
296    fn change_without_dust() {
297        let change_strategy = SingleOutputChangeStrategy::<_, MockWalletDb>::new(
298            Zip317FeeRule::standard(),
299            None,
300            ShieldedProtocol::Sapling,
301            DustOutputPolicy::default(),
302        );
303
304        // spend a single Sapling note that is sufficient to pay the fee
305        let result = change_strategy.compute_balance(
306            &Network::TestNetwork,
307            Network::TestNetwork
308                .activation_height(NetworkUpgrade::Nu5)
309                .unwrap(),
310            &[] as &[TestTransparentInput],
311            &[] as &[TxOut],
312            &(
313                sapling::builder::BundleType::DEFAULT,
314                &[TestSaplingInput {
315                    note_id: 0,
316                    value: Zatoshis::const_from_u64(55000),
317                }][..],
318                &[SaplingPayment::new(Zatoshis::const_from_u64(40000))][..],
319            ),
320            #[cfg(feature = "orchard")]
321            &orchard_fees::EmptyBundleView,
322            None,
323            &(),
324        );
325
326        assert_matches!(
327            result,
328            Ok(balance) if
329                balance.proposed_change() == [ChangeValue::sapling(Zatoshis::const_from_u64(5000), None)] &&
330                balance.fee_required() == Zatoshis::const_from_u64(10000)
331        );
332    }
333
334    #[test]
335    fn change_without_dust_multi() {
336        let change_strategy = MultiOutputChangeStrategy::<_, MockWalletDb>::new(
337            Zip317FeeRule::standard(),
338            None,
339            ShieldedProtocol::Sapling,
340            DustOutputPolicy::default(),
341            SplitPolicy::with_min_output_value(
342                NonZeroUsize::new(5).unwrap(),
343                Zatoshis::const_from_u64(100_0000),
344            ),
345        );
346
347        {
348            // spend a single Sapling note and produce 5 outputs
349            let balance = |existing_notes, total| {
350                change_strategy.compute_balance(
351                    &Network::TestNetwork,
352                    Network::TestNetwork
353                        .activation_height(NetworkUpgrade::Nu5)
354                        .unwrap(),
355                    &[] as &[TestTransparentInput],
356                    &[] as &[TxOut],
357                    &(
358                        sapling::builder::BundleType::DEFAULT,
359                        &[TestSaplingInput {
360                            note_id: 0,
361                            value: Zatoshis::const_from_u64(750_0000),
362                        }][..],
363                        &[SaplingPayment::new(Zatoshis::const_from_u64(100_0000))][..],
364                    ),
365                    #[cfg(feature = "orchard")]
366                    &orchard_fees::EmptyBundleView,
367                    None,
368                    &AccountMeta::new(Some(PoolMeta::new(existing_notes, total)), None),
369                )
370            };
371
372            assert_matches!(
373                balance(0, Zatoshis::ZERO),
374                Ok(balance) if
375                    balance.proposed_change() == [
376                        ChangeValue::sapling(Zatoshis::const_from_u64(129_4000), None),
377                        ChangeValue::sapling(Zatoshis::const_from_u64(129_4000), None),
378                        ChangeValue::sapling(Zatoshis::const_from_u64(129_4000), None),
379                        ChangeValue::sapling(Zatoshis::const_from_u64(129_4000), None),
380                        ChangeValue::sapling(Zatoshis::const_from_u64(129_4000), None),
381                    ] &&
382                    balance.fee_required() == Zatoshis::const_from_u64(30000)
383            );
384
385            assert_matches!(
386                balance(2, Zatoshis::const_from_u64(100_0000)),
387                Ok(balance) if
388                    balance.proposed_change() == [
389                        ChangeValue::sapling(Zatoshis::const_from_u64(216_0000), None),
390                        ChangeValue::sapling(Zatoshis::const_from_u64(216_0000), None),
391                        ChangeValue::sapling(Zatoshis::const_from_u64(216_0000), None),
392                    ] &&
393                    balance.fee_required() == Zatoshis::const_from_u64(20000)
394            );
395        }
396
397        {
398            // spend a single Sapling note and produce 4 outputs, as the value of the note isn't
399            // sufficient to produce 5
400            let result = change_strategy.compute_balance(
401                &Network::TestNetwork,
402                Network::TestNetwork
403                    .activation_height(NetworkUpgrade::Nu5)
404                    .unwrap(),
405                &[] as &[TestTransparentInput],
406                &[] as &[TxOut],
407                &(
408                    sapling::builder::BundleType::DEFAULT,
409                    &[TestSaplingInput {
410                        note_id: 0,
411                        value: Zatoshis::const_from_u64(600_0000),
412                    }][..],
413                    &[SaplingPayment::new(Zatoshis::const_from_u64(100_0000))][..],
414                ),
415                #[cfg(feature = "orchard")]
416                &orchard_fees::EmptyBundleView,
417                None,
418                &AccountMeta::new(
419                    Some(PoolMeta::new(0, Zatoshis::ZERO)),
420                    Some(PoolMeta::new(0, Zatoshis::ZERO)),
421                ),
422            );
423
424            assert_matches!(
425                result,
426                Ok(balance) if
427                    balance.proposed_change() == [
428                        ChangeValue::sapling(Zatoshis::const_from_u64(124_3750), None),
429                        ChangeValue::sapling(Zatoshis::const_from_u64(124_3750), None),
430                        ChangeValue::sapling(Zatoshis::const_from_u64(124_3750), None),
431                        ChangeValue::sapling(Zatoshis::const_from_u64(124_3750), None),
432                    ] &&
433                    balance.fee_required() == Zatoshis::const_from_u64(25000)
434            );
435        }
436
437        {
438            // spend a single Sapling note and produce no change outputs, as the value of outputs
439            // has been requested such that it exactly empties the wallet
440            let result = change_strategy.compute_balance(
441                &Network::TestNetwork,
442                Network::TestNetwork
443                    .activation_height(NetworkUpgrade::Nu5)
444                    .unwrap(),
445                &[] as &[TestTransparentInput],
446                &[] as &[TxOut],
447                &(
448                    sapling::builder::BundleType::DEFAULT,
449                    &[TestSaplingInput {
450                        note_id: 0,
451                        value: Zatoshis::const_from_u64(50000),
452                    }][..],
453                    &[SaplingPayment::new(Zatoshis::const_from_u64(40000))][..],
454                ),
455                #[cfg(feature = "orchard")]
456                &orchard_fees::EmptyBundleView,
457                None,
458                // after excluding the inputs we're spending, we have no notes in the wallet
459                &AccountMeta::new(
460                    Some(PoolMeta::new(0, Zatoshis::ZERO)),
461                    Some(PoolMeta::new(0, Zatoshis::ZERO)),
462                ),
463            );
464
465            assert_matches!(
466                result,
467                Ok(balance) if
468                    balance.proposed_change() == [ChangeValue::sapling(Zatoshis::ZERO, None)] &&
469                    balance.fee_required() == Zatoshis::const_from_u64(10000)
470            );
471        }
472
473        {
474            // spend a single Sapling note, with insufficient funds to cover the minimum fee.
475            let result = change_strategy.compute_balance(
476                &Network::TestNetwork,
477                Network::TestNetwork
478                    .activation_height(NetworkUpgrade::Nu5)
479                    .unwrap(),
480                &[] as &[TestTransparentInput],
481                &[] as &[TxOut],
482                &(
483                    sapling::builder::BundleType::DEFAULT,
484                    &[TestSaplingInput {
485                        note_id: 0,
486                        value: Zatoshis::const_from_u64(50000),
487                    }][..],
488                    &[SaplingPayment::new(Zatoshis::const_from_u64(40001))][..],
489                ),
490                #[cfg(feature = "orchard")]
491                &orchard_fees::EmptyBundleView,
492                None,
493                // after excluding the inputs we're spending, we have no notes in the wallet
494                &AccountMeta::new(
495                    Some(PoolMeta::new(0, Zatoshis::ZERO)),
496                    Some(PoolMeta::new(0, Zatoshis::ZERO)),
497                ),
498            );
499
500            assert_matches!(
501                result,
502                Err(ChangeError::InsufficientFunds { available, required })
503                    if available == Zatoshis::const_from_u64(50000)
504                       && required == Zatoshis::const_from_u64(50001)
505            );
506        }
507
508        {
509            // Spend a single Sapling note, creating two output notes that cause the transaction to
510            // balance exactly. This will fail, because even though there are enough funds in the
511            // wallet for the transaction to go through, and the fee is correct for a two-output
512            // transaction, we prohibit this case in order to prevent the transaction recipients
513            // from being able to reason about the value of the input note via knowledge that there
514            // is no change output.
515            let result = change_strategy.compute_balance(
516                &Network::TestNetwork,
517                Network::TestNetwork
518                    .activation_height(NetworkUpgrade::Nu5)
519                    .unwrap(),
520                &[] as &[TestTransparentInput],
521                &[] as &[TxOut],
522                &(
523                    sapling::builder::BundleType::DEFAULT,
524                    &[TestSaplingInput {
525                        note_id: 0,
526                        value: Zatoshis::const_from_u64(50000),
527                    }][..],
528                    &[
529                        SaplingPayment::new(Zatoshis::const_from_u64(30000)),
530                        SaplingPayment::new(Zatoshis::const_from_u64(10000)),
531                    ][..],
532                ),
533                #[cfg(feature = "orchard")]
534                &orchard_fees::EmptyBundleView,
535                None,
536                // after excluding the inputs we're spending, we have no notes in the wallet
537                &AccountMeta::new(
538                    Some(PoolMeta::new(0, Zatoshis::ZERO)),
539                    Some(PoolMeta::new(0, Zatoshis::ZERO)),
540                ),
541            );
542
543            assert_matches!(
544                result,
545                Err(ChangeError::InsufficientFunds { available, required })
546                    if available == Zatoshis::const_from_u64(50000)
547                       && required == Zatoshis::const_from_u64(55000)
548            );
549        }
550    }
551
552    #[test]
553    #[cfg(feature = "orchard")]
554    fn cross_pool_change_without_dust() {
555        let change_strategy = SingleOutputChangeStrategy::<_, MockWalletDb>::new(
556            Zip317FeeRule::standard(),
557            None,
558            ShieldedProtocol::Orchard,
559            DustOutputPolicy::default(),
560        );
561
562        // spend a single Sapling note that is sufficient to pay the fee
563        let result = change_strategy.compute_balance(
564            &Network::TestNetwork,
565            Network::TestNetwork
566                .activation_height(NetworkUpgrade::Nu5)
567                .unwrap(),
568            &[] as &[TestTransparentInput],
569            &[] as &[TxOut],
570            &(
571                sapling::builder::BundleType::DEFAULT,
572                &[TestSaplingInput {
573                    note_id: 0,
574                    value: Zatoshis::const_from_u64(55000),
575                }][..],
576                &[] as &[Infallible],
577            ),
578            &(
579                orchard::builder::BundleType::DEFAULT,
580                &[] as &[Infallible],
581                &[OrchardPayment::new(Zatoshis::const_from_u64(30000))][..],
582            ),
583            None,
584            &(),
585        );
586
587        assert_matches!(
588            result,
589            Ok(balance) if
590                balance.proposed_change() == [ChangeValue::orchard(Zatoshis::const_from_u64(5000), None)] &&
591                balance.fee_required() == Zatoshis::const_from_u64(20000)
592        );
593    }
594
595    #[test]
596    fn change_with_transparent_payments_implicitly_allowing_zero_change() {
597        change_with_transparent_payments(DustOutputPolicy::default())
598    }
599
600    #[test]
601    fn change_with_transparent_payments_explicitly_allowing_zero_change() {
602        change_with_transparent_payments(DustOutputPolicy::new(
603            DustAction::AllowDustChange,
604            Some(Zatoshis::ZERO),
605        ))
606    }
607
608    fn change_with_transparent_payments(dust_output_policy: DustOutputPolicy) {
609        let change_strategy = SingleOutputChangeStrategy::<_, MockWalletDb>::new(
610            Zip317FeeRule::standard(),
611            None,
612            ShieldedProtocol::Sapling,
613            dust_output_policy,
614        );
615
616        // spend a single Sapling note that is sufficient to pay the fee
617        let result = change_strategy.compute_balance(
618            &Network::TestNetwork,
619            Network::TestNetwork
620                .activation_height(NetworkUpgrade::Nu5)
621                .unwrap(),
622            &[] as &[TestTransparentInput],
623            &[TxOut {
624                value: Zatoshis::const_from_u64(40000),
625                script_pubkey: Script(vec![]),
626            }],
627            &(
628                sapling::builder::BundleType::DEFAULT,
629                &[TestSaplingInput {
630                    note_id: 0,
631                    value: Zatoshis::const_from_u64(55000),
632                }][..],
633                &[] as &[Infallible],
634            ),
635            #[cfg(feature = "orchard")]
636            &orchard_fees::EmptyBundleView,
637            None,
638            &(),
639        );
640
641        assert_matches!(
642            result,
643            Ok(balance) if
644                balance.proposed_change() == [ChangeValue::sapling(Zatoshis::ZERO, None)]
645                && balance.fee_required() == Zatoshis::const_from_u64(15000)
646        );
647    }
648
649    #[test]
650    #[cfg(feature = "transparent-inputs")]
651    fn change_fully_transparent_no_change() {
652        use crate::fees::sapling as sapling_fees;
653        use ::transparent::{address::TransparentAddress, bundle::OutPoint};
654
655        let change_strategy = SingleOutputChangeStrategy::<_, MockWalletDb>::new(
656            Zip317FeeRule::standard(),
657            None,
658            ShieldedProtocol::Sapling,
659            DustOutputPolicy::default(),
660        );
661
662        // Spend a single transparent UTXO that is exactly sufficient to pay the fee.
663        let result = change_strategy.compute_balance::<_, Infallible>(
664            &Network::TestNetwork,
665            Network::TestNetwork
666                .activation_height(NetworkUpgrade::Nu5)
667                .unwrap(),
668            &[TestTransparentInput {
669                outpoint: OutPoint::fake(),
670                coin: TxOut {
671                    value: Zatoshis::const_from_u64(50000),
672                    script_pubkey: TransparentAddress::PublicKeyHash([0u8; 20]).script(),
673                },
674            }],
675            &[TxOut {
676                value: Zatoshis::const_from_u64(40000),
677                script_pubkey: Script(vec![]),
678            }],
679            &sapling_fees::EmptyBundleView,
680            #[cfg(feature = "orchard")]
681            &orchard_fees::EmptyBundleView,
682            None,
683            &(),
684        );
685
686        assert_matches!(
687            result,
688            Ok(balance) if
689                balance.proposed_change().is_empty() &&
690                balance.fee_required() == Zatoshis::const_from_u64(10000)
691        );
692    }
693
694    #[test]
695    #[cfg(feature = "transparent-inputs")]
696    fn change_transparent_flows_with_shielded_change() {
697        use crate::fees::sapling as sapling_fees;
698        use ::transparent::{address::TransparentAddress, bundle::OutPoint};
699
700        let change_strategy = SingleOutputChangeStrategy::<_, MockWalletDb>::new(
701            Zip317FeeRule::standard(),
702            None,
703            ShieldedProtocol::Sapling,
704            DustOutputPolicy::default(),
705        );
706
707        // Spend a single transparent UTXO that is sufficient to pay the fee.
708        let result = change_strategy.compute_balance::<_, Infallible>(
709            &Network::TestNetwork,
710            Network::TestNetwork
711                .activation_height(NetworkUpgrade::Nu5)
712                .unwrap(),
713            &[TestTransparentInput {
714                outpoint: OutPoint::fake(),
715                coin: TxOut {
716                    value: Zatoshis::const_from_u64(63000),
717                    script_pubkey: TransparentAddress::PublicKeyHash([0u8; 20]).script(),
718                },
719            }],
720            &[TxOut {
721                value: Zatoshis::const_from_u64(40000),
722                script_pubkey: Script(vec![]),
723            }],
724            &sapling_fees::EmptyBundleView,
725            #[cfg(feature = "orchard")]
726            &orchard_fees::EmptyBundleView,
727            None,
728            &(),
729        );
730
731        assert_matches!(
732            result,
733            Ok(balance) if
734                balance.proposed_change() == [ChangeValue::sapling(Zatoshis::const_from_u64(8000), None)] &&
735                balance.fee_required() == Zatoshis::const_from_u64(15000)
736        );
737    }
738
739    #[test]
740    #[cfg(feature = "transparent-inputs")]
741    fn change_transparent_flows_with_shielded_dust_change() {
742        use crate::fees::sapling as sapling_fees;
743        use ::transparent::{address::TransparentAddress, bundle::OutPoint};
744
745        let change_strategy = SingleOutputChangeStrategy::<_, MockWalletDb>::new(
746            Zip317FeeRule::standard(),
747            None,
748            ShieldedProtocol::Sapling,
749            DustOutputPolicy::new(
750                DustAction::AllowDustChange,
751                Some(Zatoshis::const_from_u64(1000)),
752            ),
753        );
754
755        // Spend a single transparent UTXO that is sufficient to pay the fee.
756        // The change will go to the fallback shielded change pool even though all inputs
757        // and payments are transparent, and even though the change amount (1000) would
758        // normally be considered dust, because we set the dust policy to allow that.
759        let result = change_strategy.compute_balance::<_, Infallible>(
760            &Network::TestNetwork,
761            Network::TestNetwork
762                .activation_height(NetworkUpgrade::Nu5)
763                .unwrap(),
764            &[TestTransparentInput {
765                outpoint: OutPoint::fake(),
766                coin: TxOut {
767                    value: Zatoshis::const_from_u64(56000),
768                    script_pubkey: TransparentAddress::PublicKeyHash([0u8; 20]).script(),
769                },
770            }],
771            &[TxOut {
772                value: Zatoshis::const_from_u64(40000),
773                script_pubkey: Script(vec![]),
774            }],
775            &sapling_fees::EmptyBundleView,
776            #[cfg(feature = "orchard")]
777            &orchard_fees::EmptyBundleView,
778            None,
779            &(),
780        );
781
782        assert_matches!(
783            result,
784            Ok(balance) if
785                balance.proposed_change() == [ChangeValue::sapling(Zatoshis::const_from_u64(1000), None)] &&
786                balance.fee_required() == Zatoshis::const_from_u64(15000)
787        );
788    }
789
790    #[test]
791    fn change_with_allowable_dust_implicitly_allowing_zero_change() {
792        change_with_allowable_dust(DustOutputPolicy::default())
793    }
794
795    #[test]
796    fn change_with_allowable_dust_explicitly_allowing_zero_change() {
797        change_with_allowable_dust(DustOutputPolicy::new(
798            DustAction::AllowDustChange,
799            Some(Zatoshis::ZERO),
800        ))
801    }
802
803    fn change_with_allowable_dust(dust_output_policy: DustOutputPolicy) {
804        let change_strategy = SingleOutputChangeStrategy::<_, MockWalletDb>::new(
805            Zip317FeeRule::standard(),
806            None,
807            ShieldedProtocol::Sapling,
808            dust_output_policy,
809        );
810
811        // Spend two Sapling notes, one of them dust. There is sufficient to
812        // pay the fee: if only one note is spent then we are 1000 short, but
813        // if both notes are spent then the fee stays at 10000 (even with a
814        // zero-valued change output), so we have just enough.
815        let result = change_strategy.compute_balance(
816            &Network::TestNetwork,
817            Network::TestNetwork
818                .activation_height(NetworkUpgrade::Nu5)
819                .unwrap(),
820            &[] as &[TestTransparentInput],
821            &[] as &[TxOut],
822            &(
823                sapling::builder::BundleType::DEFAULT,
824                &[
825                    TestSaplingInput {
826                        note_id: 0,
827                        value: Zatoshis::const_from_u64(49000),
828                    },
829                    TestSaplingInput {
830                        note_id: 1,
831                        value: Zatoshis::const_from_u64(1000),
832                    },
833                ][..],
834                &[SaplingPayment::new(Zatoshis::const_from_u64(40000))][..],
835            ),
836            #[cfg(feature = "orchard")]
837            &orchard_fees::EmptyBundleView,
838            None,
839            &(),
840        );
841
842        assert_matches!(
843            result,
844            Ok(balance) if
845                balance.proposed_change() == [ChangeValue::sapling(Zatoshis::ZERO, None)] &&
846                balance.fee_required() == Zatoshis::const_from_u64(10000)
847        );
848    }
849
850    #[test]
851    fn change_with_disallowed_dust() {
852        let change_strategy = SingleOutputChangeStrategy::<_, MockWalletDb>::new(
853            Zip317FeeRule::standard(),
854            None,
855            ShieldedProtocol::Sapling,
856            DustOutputPolicy::default(),
857        );
858
859        // Attempt to spend three Sapling notes, one of them dust. Adding the third
860        // note increases the number of actions, and so it is uneconomic to spend it.
861        let result = change_strategy.compute_balance(
862            &Network::TestNetwork,
863            Network::TestNetwork
864                .activation_height(NetworkUpgrade::Nu5)
865                .unwrap(),
866            &[] as &[TestTransparentInput],
867            &[] as &[TxOut],
868            &(
869                sapling::builder::BundleType::DEFAULT,
870                &[
871                    TestSaplingInput {
872                        note_id: 0,
873                        value: Zatoshis::const_from_u64(29000),
874                    },
875                    TestSaplingInput {
876                        note_id: 1,
877                        value: Zatoshis::const_from_u64(20000),
878                    },
879                    TestSaplingInput {
880                        note_id: 2,
881                        value: Zatoshis::const_from_u64(1000),
882                    },
883                ][..],
884                &[SaplingPayment::new(Zatoshis::const_from_u64(30000))][..],
885            ),
886            #[cfg(feature = "orchard")]
887            &orchard_fees::EmptyBundleView,
888            None,
889            &(),
890        );
891
892        // We will get an error here, because the dust input isn't free to add
893        // to the transaction.
894        assert_matches!(
895            result,
896            Err(ChangeError::DustInputs { sapling, .. }) if sapling == vec![2]
897        );
898    }
899}