Skip to main content

dpp/shielded/builder/
shielded_withdrawal.rs

1use grovedb_commitment_tree::{Anchor, FullViewingKey, SpendAuthorizingKey};
2
3use crate::address_funds::OrchardAddress;
4use crate::fee::Credits;
5use crate::identity::core_script::CoreScript;
6use crate::shielded::compute_minimum_shielded_fee;
7use crate::state_transition::shielded_withdrawal_transition::methods::ShieldedWithdrawalTransitionMethodsV0;
8use crate::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition;
9use crate::state_transition::StateTransition;
10use crate::withdrawal::Pooling;
11use crate::ProtocolError;
12use platform_version::version::PlatformVersion;
13
14use super::{build_spend_bundle, serialize_authorized_bundle, OrchardProver, SpendableNote};
15
16/// Builds a ShieldedWithdrawal state transition (shielded pool -> core L1 address).
17///
18/// Spends existing notes and withdraws value to a core chain script output.
19/// The shielded fee is deducted from the spent notes. Any remaining value is
20/// returned to the shielded `change_address`.
21///
22/// # Parameters
23/// - `spends` - Notes to spend with their Merkle paths
24/// - `withdrawal_amount` - Amount to withdraw to the core chain
25/// - `output_script` - Core chain script to receive the funds
26/// - `core_fee_per_byte` - Core chain fee rate
27/// - `pooling` - Withdrawal pooling strategy
28/// - `change_address` - Orchard address for change output
29/// - `fvk` - Full viewing key for spend authorization
30/// - `ask` - Spend authorizing key for RedPallas signatures
31/// - `anchor` - Sinsemilla root of the note commitment tree (Orchard Anchor)
32/// - `prover` - Orchard prover (holds the Halo 2 proving key)
33/// - `memo` - 36-byte structured memo for the change output (4-byte type tag + 32-byte payload)
34/// - `fee` - Optional fee override; if `None`, the minimum fee is computed automatically.
35///   If `Some`, must be >= the minimum fee.
36/// - `platform_version` - Protocol version
37#[allow(clippy::too_many_arguments)]
38pub fn build_shielded_withdrawal_transition<P: OrchardProver>(
39    spends: Vec<SpendableNote>,
40    withdrawal_amount: u64,
41    output_script: CoreScript,
42    core_fee_per_byte: u32,
43    pooling: Pooling,
44    change_address: &OrchardAddress,
45    fvk: &FullViewingKey,
46    ask: &SpendAuthorizingKey,
47    anchor: Anchor,
48    prover: &P,
49    memo: [u8; 36],
50    fee: Option<Credits>,
51    platform_version: &PlatformVersion,
52) -> Result<StateTransition, ProtocolError> {
53    if withdrawal_amount > i64::MAX as u64 {
54        return Err(ProtocolError::ShieldedBuildError(format!(
55            "withdrawal amount {} exceeds maximum allowed value {}",
56            withdrawal_amount,
57            i64::MAX as u64
58        )));
59    }
60
61    let total_spent: u64 = spends.iter().map(|s| s.note.value().inner()).sum();
62
63    // Conservative action count: at least (spends, 1) since we have a change output.
64    let num_actions = spends.len().max(1);
65    let min_fee = compute_minimum_shielded_fee(num_actions, platform_version);
66    let effective_fee = match fee {
67        Some(f) if f < min_fee => {
68            return Err(ProtocolError::ShieldedBuildError(format!(
69                "fee {} is below minimum required fee {}",
70                f, min_fee
71            )));
72        }
73        Some(f) if f > min_fee.saturating_mul(1000) => {
74            return Err(ProtocolError::ShieldedBuildError(format!(
75                "fee {} exceeds 1000x the minimum fee {}",
76                f, min_fee
77            )));
78        }
79        Some(f) => f,
80        None => min_fee,
81    };
82
83    let required = withdrawal_amount
84        .checked_add(effective_fee)
85        .ok_or_else(|| {
86            ProtocolError::ShieldedBuildError("fee + withdrawal_amount overflows u64".to_string())
87        })?;
88    if required > total_spent {
89        return Err(ProtocolError::ShieldedBuildError(format!(
90            "withdrawal amount {} + fee {} = {} exceeds total spendable value {}",
91            withdrawal_amount, effective_fee, required, total_spent
92        )));
93    }
94
95    let change_amount = total_spent - required;
96
97    // ShieldedWithdrawal extra_data = output_script || value_balance (le bytes)
98    // value_balance = withdrawal_amount + fee, becomes v0.unshielding_amount in the state transition
99    // Must match server-side sighash in shielded_proof.rs
100    let mut extra_sighash_data = output_script.as_bytes().to_vec();
101    extra_sighash_data.extend_from_slice(&required.to_le_bytes());
102
103    let bundle = build_spend_bundle(
104        spends,
105        change_address,
106        change_amount,
107        memo,
108        fvk,
109        ask,
110        anchor,
111        prover,
112        &extra_sighash_data,
113    )?;
114
115    let sb = serialize_authorized_bundle(&bundle);
116
117    ShieldedWithdrawalTransition::try_from_bundle(
118        sb.actions,
119        sb.value_balance as u64,
120        sb.anchor,
121        sb.proof,
122        sb.binding_signature,
123        core_fee_per_byte,
124        pooling,
125        output_script,
126        platform_version,
127    )
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::shielded::builder::test_helpers::{
134        test_orchard_address, test_spendable_note, TestProver,
135    };
136
137    #[test]
138    fn test_shielded_withdrawal_fee_below_minimum() {
139        let platform_version = PlatformVersion::latest();
140        let change_address = test_orchard_address();
141
142        let note = test_spendable_note(1_000_000);
143        let spends = vec![note];
144
145        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
146            .expect("valid spending key bytes");
147        let fvk = FullViewingKey::from(&sk);
148        let ask = SpendAuthorizingKey::from(&sk);
149
150        let result = build_shielded_withdrawal_transition(
151            spends,
152            100,
153            CoreScript::new_p2pkh([1u8; 20]), // minimal P2PKH prefix
154            1,
155            Pooling::Never,
156            &change_address,
157            &fvk,
158            &ask,
159            Anchor::empty_tree(),
160            &TestProver,
161            [0u8; 36],
162            Some(1), // fee = 1, should be below minimum
163            platform_version,
164        );
165
166        assert!(result.is_err());
167        let err = result.unwrap_err().to_string();
168        assert!(
169            err.contains("below minimum required fee"),
170            "unexpected error: {}",
171            err
172        );
173    }
174
175    #[test]
176    fn test_shielded_withdrawal_fee_above_upper_bound() {
177        let platform_version = PlatformVersion::latest();
178        let change_address = test_orchard_address();
179
180        let note = test_spendable_note(u64::MAX);
181        let spends = vec![note];
182
183        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
184            .expect("valid spending key bytes");
185        let fvk = FullViewingKey::from(&sk);
186        let ask = SpendAuthorizingKey::from(&sk);
187
188        // Compute the minimum fee so we can exceed the 1000x bound
189        let num_actions = 1usize;
190        let min_fee = crate::shielded::compute_minimum_shielded_fee(num_actions, platform_version);
191        let excessive_fee = min_fee.saturating_mul(1000) + 1;
192
193        let result = build_shielded_withdrawal_transition(
194            spends,
195            100,
196            CoreScript::new_p2pkh([1u8; 20]),
197            1,
198            Pooling::Never,
199            &change_address,
200            &fvk,
201            &ask,
202            Anchor::empty_tree(),
203            &TestProver,
204            [0u8; 36],
205            Some(excessive_fee),
206            platform_version,
207        );
208
209        assert!(result.is_err());
210        let err = result.unwrap_err().to_string();
211        assert!(
212            err.contains("exceeds 1000x the minimum fee"),
213            "unexpected error: {}",
214            err
215        );
216    }
217
218    #[test]
219    fn test_shielded_withdrawal_insufficient_funds() {
220        let platform_version = PlatformVersion::latest();
221        let change_address = test_orchard_address();
222
223        let note = test_spendable_note(100);
224        let spends = vec![note];
225
226        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
227            .expect("valid spending key bytes");
228        let fvk = FullViewingKey::from(&sk);
229        let ask = SpendAuthorizingKey::from(&sk);
230
231        let result = build_shielded_withdrawal_transition(
232            spends,
233            1_000_000,
234            CoreScript::new_p2pkh([1u8; 20]),
235            1,
236            Pooling::Never,
237            &change_address,
238            &fvk,
239            &ask,
240            Anchor::empty_tree(),
241            &TestProver,
242            [0u8; 36],
243            None,
244            platform_version,
245        );
246
247        assert!(result.is_err());
248        let err = result.unwrap_err().to_string();
249        assert!(
250            err.contains("exceeds total spendable value"),
251            "unexpected error: {}",
252            err
253        );
254    }
255
256    // --------------------------------------------------------------
257    // Extra coverage — upper-bound / overflow / default branches
258    // --------------------------------------------------------------
259
260    #[test]
261    fn test_shielded_withdrawal_amount_exceeds_i64_max_errors() {
262        // `withdrawal_amount > i64::MAX as u64` is the first check and has
263        // its own error branch.
264        let platform_version = PlatformVersion::latest();
265        let change_address = test_orchard_address();
266
267        let note = test_spendable_note(u64::MAX);
268        let spends = vec![note];
269
270        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
271        let fvk = FullViewingKey::from(&sk);
272        let ask = SpendAuthorizingKey::from(&sk);
273
274        let result = build_shielded_withdrawal_transition(
275            spends,
276            (i64::MAX as u64) + 1, // exceeds the i64 limit
277            CoreScript::new_p2pkh([1u8; 20]),
278            1,
279            Pooling::Never,
280            &change_address,
281            &fvk,
282            &ask,
283            Anchor::empty_tree(),
284            &TestProver,
285            [0u8; 36],
286            None,
287            platform_version,
288        );
289        assert!(result.is_err());
290        let err = result.unwrap_err().to_string();
291        assert!(
292            err.contains("exceeds maximum allowed value"),
293            "unexpected error: {}",
294            err
295        );
296    }
297
298    #[test]
299    fn test_shielded_withdrawal_fee_at_exact_upper_bound_accepted() {
300        // Boundary test: fee == 1000x the minimum is the *accepted* upper
301        // limit (strictly > is rejected). The builder should proceed past
302        // the fee validation and only fail later at add_spend.
303        let platform_version = PlatformVersion::latest();
304        let change_address = test_orchard_address();
305
306        let note = test_spendable_note(u64::MAX);
307        let spends = vec![note];
308
309        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
310        let fvk = FullViewingKey::from(&sk);
311        let ask = SpendAuthorizingKey::from(&sk);
312
313        let min_fee = crate::shielded::compute_minimum_shielded_fee(1, platform_version);
314        let fee_at_boundary = min_fee.saturating_mul(1000);
315
316        let result = build_shielded_withdrawal_transition(
317            spends,
318            100,
319            CoreScript::new_p2pkh([1u8; 20]),
320            1,
321            Pooling::Never,
322            &change_address,
323            &fvk,
324            &ask,
325            Anchor::empty_tree(),
326            &TestProver,
327            [0u8; 36],
328            Some(fee_at_boundary),
329            platform_version,
330        );
331        // Boundary value passes the validation, so it must NOT fail with
332        // "exceeds 1000x". A successful build is also acceptable; only a
333        // later-stage failure (anchor/add_spend) should surface — and never
334        // as the upper-bound error.
335        if let Err(err) = result {
336            let err = err.to_string();
337            assert!(
338                !err.contains("exceeds 1000x"),
339                "boundary value should not trigger upper-bound error: {}",
340                err
341            );
342        }
343    }
344
345    #[test]
346    fn test_shielded_withdrawal_fee_at_exact_min_accepted() {
347        // Boundary test: fee == min_fee should be accepted (strictly `<`
348        // is rejected).
349        let platform_version = PlatformVersion::latest();
350        let change_address = test_orchard_address();
351
352        let note = test_spendable_note(1_000_000);
353        let spends = vec![note];
354
355        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
356        let fvk = FullViewingKey::from(&sk);
357        let ask = SpendAuthorizingKey::from(&sk);
358
359        let min_fee = crate::shielded::compute_minimum_shielded_fee(1, platform_version);
360
361        let result = build_shielded_withdrawal_transition(
362            spends,
363            100,
364            CoreScript::new_p2pkh([1u8; 20]),
365            1,
366            Pooling::Never,
367            &change_address,
368            &fvk,
369            &ask,
370            Anchor::empty_tree(),
371            &TestProver,
372            [0u8; 36],
373            Some(min_fee),
374            platform_version,
375        );
376        // Fee == min_fee is accepted by validation; a successful build is
377        // fine. Only a later-stage failure should surface — and never with
378        // the "below minimum required fee" message.
379        if let Err(err) = result {
380            let err = err.to_string();
381            assert!(
382                !err.contains("below minimum required fee"),
383                "fee at min must not trip the lower bound: {}",
384                err
385            );
386        }
387    }
388
389    #[test]
390    fn test_shielded_withdrawal_zero_spends_errors() {
391        // Empty spends vec → total_spent = 0 and num_actions = 1 (max).
392        let platform_version = PlatformVersion::latest();
393        let change_address = test_orchard_address();
394
395        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
396        let fvk = FullViewingKey::from(&sk);
397        let ask = SpendAuthorizingKey::from(&sk);
398
399        let result = build_shielded_withdrawal_transition(
400            vec![],
401            1,
402            CoreScript::new_p2pkh([1u8; 20]),
403            1,
404            Pooling::Never,
405            &change_address,
406            &fvk,
407            &ask,
408            Anchor::empty_tree(),
409            &TestProver,
410            [0u8; 36],
411            None,
412            platform_version,
413        );
414        assert!(result.is_err());
415        let err = result.unwrap_err().to_string();
416        assert!(
417            err.contains("exceeds total spendable value"),
418            "unexpected error: {}",
419            err
420        );
421    }
422}