Skip to main content

dpp/shielded/builder/
unshield.rs

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