Skip to main content

zcash_client_backend/fees/
fixed.rs

1//! Change strategies designed for use with a fixed fee.
2
3use core::marker::PhantomData;
4
5use zcash_primitives::transaction::fees::{fixed::FeeRule as FixedFeeRule, transparent};
6use zcash_protocol::{
7    ShieldedPool,
8    consensus::{self, BlockHeight},
9    memo::MemoBytes,
10    value::{BalanceError, Zatoshis},
11};
12
13use crate::data_api::anchor_retention::PoolMigrationParams;
14use crate::data_api::{ChangeReceivers, InputSource, wallet::TargetHeight};
15
16use super::{
17    ChangeError, ChangeStrategy, DustOutputPolicy, EphemeralBalance, SplitPolicy,
18    TransactionBalance,
19    common::{SinglePoolBalanceConfig, single_pool_output_balance},
20    sapling as sapling_fees,
21};
22
23#[cfg(feature = "transparent-inputs")]
24use super::TransparentChangePolicy;
25#[cfg(feature = "orchard")]
26use super::orchard as orchard_fees;
27#[cfg(feature = "orchard")]
28use zcash_primitives::transaction::builder::BundlePadding;
29
30/// A change strategy that proposes change as a single output. The output pool is chosen
31/// as the most current pool that avoids unnecessary pool-crossing (with a specified
32/// fallback when the transaction has no shielded inputs). Fee calculation is delegated
33/// to the provided fee rule.
34pub struct SingleOutputChangeStrategy<I> {
35    fee_rule: FixedFeeRule,
36    change_memo: Option<MemoBytes>,
37    fallback_change_pool: ShieldedPool,
38    dust_output_policy: DustOutputPolicy,
39    #[cfg(feature = "transparent-inputs")]
40    transparent_change_policy: TransparentChangePolicy,
41    meta_source: PhantomData<I>,
42}
43
44impl<I> SingleOutputChangeStrategy<I> {
45    /// Constructs a new [`SingleOutputChangeStrategy`] with the specified fee rule
46    /// and change memo.
47    ///
48    /// `fallback_change_pool` is used when more than one shielded pool is enabled via
49    /// feature flags, and the transaction has no shielded inputs.
50    pub fn new(
51        fee_rule: FixedFeeRule,
52        change_memo: Option<MemoBytes>,
53        fallback_change_pool: ShieldedPool,
54        dust_output_policy: DustOutputPolicy,
55    ) -> Self {
56        Self {
57            fee_rule,
58            change_memo,
59            fallback_change_pool,
60            dust_output_policy,
61            #[cfg(feature = "transparent-inputs")]
62            transparent_change_policy: TransparentChangePolicy::ShieldChange,
63            meta_source: PhantomData,
64        }
65    }
66
67    /// Sets the [`TransparentChangePolicy`] to be used by this change strategy, determining
68    /// whether change may be returned to the transparent pool when the flows of the transaction
69    /// under construction are fully transparent.
70    ///
71    /// The default is [`TransparentChangePolicy::ShieldChange`]. This policy has no effect on
72    /// transactions that involve any shielded flows.
73    #[cfg(feature = "transparent-inputs")]
74    pub fn with_transparent_change_policy(
75        mut self,
76        transparent_change_policy: TransparentChangePolicy,
77    ) -> Self {
78        self.transparent_change_policy = transparent_change_policy;
79        self
80    }
81}
82
83impl<I: InputSource> ChangeStrategy for SingleOutputChangeStrategy<I> {
84    type FeeRule = FixedFeeRule;
85    type Error = BalanceError;
86    type MetaSource = I;
87    type AccountMetaT = ChangeReceivers;
88
89    fn fee_rule(&self) -> &Self::FeeRule {
90        &self.fee_rule
91    }
92
93    fn fetch_wallet_meta(
94        &self,
95        meta_source: &Self::MetaSource,
96        account: <Self::MetaSource as InputSource>::AccountId,
97        _target_height: TargetHeight,
98        _exclude: &[<Self::MetaSource as crate::data_api::InputSource>::NoteRef],
99    ) -> Result<Self::AccountMetaT, <Self::MetaSource as crate::data_api::InputSource>::Error> {
100        meta_source.get_change_receivers(account)
101    }
102
103    fn compute_balance<P: consensus::Parameters, NoteRefT: Clone>(
104        &self,
105        params: &P,
106        target_height: TargetHeight,
107        anchor_height: BlockHeight,
108        zip318: &PoolMigrationParams,
109        transparent_inputs: &[impl transparent::InputView],
110        transparent_outputs: &[impl transparent::OutputView],
111        sapling: &impl sapling_fees::BundleView<NoteRefT>,
112        #[cfg(feature = "orchard")] orchard: &impl orchard_fees::BundleView<NoteRefT>,
113        #[cfg(feature = "orchard")] ironwood: &impl orchard_fees::BundleView<NoteRefT>,
114        ephemeral_balance: Option<EphemeralBalance>,
115        wallet_meta: &Self::AccountMetaT,
116    ) -> Result<TransactionBalance, ChangeError<Self::Error, NoteRefT>> {
117        let split_policy = SplitPolicy::single_output();
118        let cfg = SinglePoolBalanceConfig::new(
119            params,
120            &self.fee_rule,
121            &self.dust_output_policy,
122            self.fee_rule.fixed_fee(),
123            &split_policy,
124            self.fallback_change_pool,
125            #[cfg(feature = "transparent-inputs")]
126            self.transparent_change_policy,
127            Zatoshis::ZERO,
128            0,
129        );
130
131        single_pool_output_balance(
132            cfg,
133            None,
134            wallet_meta,
135            target_height,
136            transparent_inputs,
137            transparent_outputs,
138            sapling,
139            #[cfg(feature = "orchard")]
140            orchard,
141            #[cfg(feature = "orchard")]
142            ironwood,
143            // The fixed-fee strategy has no unpadded opt-in; keep the padded default.
144            #[cfg(feature = "orchard")]
145            BundlePadding::DEFAULT,
146            anchor_height,
147            zip318,
148            self.change_memo.as_ref(),
149            ephemeral_balance,
150        )
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use crate::data_api::anchor_retention::{AnchorRetentionInterval, PoolMigrationParams};
157    use crate::fees::tests::all_change_receivers;
158    use ::transparent::bundle::TxOut;
159    use zcash_primitives::transaction::fees::{
160        fixed::FeeRule as FixedFeeRule, zip317::MINIMUM_FEE,
161    };
162    use zcash_protocol::consensus::BlockHeight;
163    use zcash_protocol::{
164        ShieldedPool,
165        consensus::{Network, NetworkUpgrade, Parameters},
166        value::Zatoshis,
167    };
168
169    use super::SingleOutputChangeStrategy;
170    use crate::{
171        data_api::{testing::MockWalletDb, wallet::input_selection::SaplingPayment},
172        fees::{
173            ChangeError, ChangeStrategy, ChangeValue, DustOutputPolicy,
174            tests::{TestSaplingInput, TestTransparentInput},
175        },
176    };
177
178    #[cfg(feature = "orchard")]
179    use crate::fees::orchard as orchard_fees;
180
181    #[test]
182    fn change_without_dust() {
183        let fee_rule = FixedFeeRule::non_standard(MINIMUM_FEE);
184        let change_strategy = SingleOutputChangeStrategy::<MockWalletDb>::new(
185            fee_rule,
186            None,
187            ShieldedPool::Sapling,
188            DustOutputPolicy::default(),
189        );
190
191        // spend a single Sapling note that is sufficient to pay the fee
192        let result = change_strategy.compute_balance(
193            &Network::TestNetwork,
194            Network::TestNetwork
195                .activation_height(NetworkUpgrade::Nu5)
196                .unwrap()
197                .into(),
198            BlockHeight::from_u32(1),
199            &PoolMigrationParams::new(
200                zcash_protocol::zip318::BlockSpacing::UNIFORM,
201                AnchorRetentionInterval::ZIP_318,
202            ),
203            &[] as &[TestTransparentInput],
204            &[] as &[TxOut],
205            &(
206                sapling::builder::BundleType::DEFAULT,
207                &[TestSaplingInput {
208                    note_id: 0,
209                    value: Zatoshis::const_from_u64(60000),
210                }][..],
211                &[SaplingPayment::new(Zatoshis::const_from_u64(40000))][..],
212            ),
213            #[cfg(feature = "orchard")]
214            &orchard_fees::EmptyBundleView,
215            #[cfg(feature = "orchard")]
216            &orchard_fees::EmptyBundleView,
217            None,
218            &all_change_receivers(),
219        );
220
221        assert_matches!(
222            result,
223            Ok(balance) if
224                balance.proposed_change() == [ChangeValue::sapling(Zatoshis::const_from_u64(10000), None)] &&
225                balance.fee_required() == MINIMUM_FEE
226        );
227    }
228
229    #[test]
230    fn dust_change() {
231        let fee_rule = FixedFeeRule::non_standard(MINIMUM_FEE);
232        let change_strategy = SingleOutputChangeStrategy::<MockWalletDb>::new(
233            fee_rule,
234            None,
235            ShieldedPool::Sapling,
236            DustOutputPolicy::default(),
237        );
238
239        // spend a single Sapling note that is sufficient to pay the fee
240        let result = change_strategy.compute_balance(
241            &Network::TestNetwork,
242            Network::TestNetwork
243                .activation_height(NetworkUpgrade::Nu5)
244                .unwrap()
245                .into(),
246            BlockHeight::from_u32(1),
247            &PoolMigrationParams::new(
248                zcash_protocol::zip318::BlockSpacing::UNIFORM,
249                AnchorRetentionInterval::ZIP_318,
250            ),
251            &[] as &[TestTransparentInput],
252            &[] as &[TxOut],
253            &(
254                sapling::builder::BundleType::DEFAULT,
255                &[
256                    TestSaplingInput {
257                        note_id: 0,
258                        value: Zatoshis::const_from_u64(40000),
259                    },
260                    // enough to pay a fee, plus dust
261                    TestSaplingInput {
262                        note_id: 0,
263                        value: Zatoshis::const_from_u64(10100),
264                    },
265                ][..],
266                &[SaplingPayment::new(Zatoshis::const_from_u64(40000))][..],
267            ),
268            #[cfg(feature = "orchard")]
269            &orchard_fees::EmptyBundleView,
270            #[cfg(feature = "orchard")]
271            &orchard_fees::EmptyBundleView,
272            None,
273            &all_change_receivers(),
274        );
275
276        assert_matches!(
277            result,
278            Err(ChangeError::InsufficientFunds { available, required })
279            if available == Zatoshis::const_from_u64(50100) && required == Zatoshis::const_from_u64(60000)
280        );
281    }
282}