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_shielded_unshield_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`; the change note is
19/// encrypted with the sender's External-scope OVK (derived from `fvk`) so the
20/// wallet can recover it — including the structured memo — via OVK recovery.
21///
22/// # Parameters
23/// - `spends` - Notes to spend with their Merkle paths
24/// - `output_address` - Platform address to receive the unshielded funds
25/// - `unshield_amount` - Amount to unshield to the platform address
26/// - `change_address` - Orchard address for change output
27/// - `fvk` - Full viewing key for spend authorization
28/// - `ask` - Spend authorizing key for RedPallas signatures
29/// - `anchor` - Sinsemilla root of the note commitment tree (Orchard Anchor)
30/// - `prover` - Orchard prover (holds the Halo 2 proving key)
31/// - `memo` - 36-byte structured memo for the change output (4-byte type tag + 32-byte payload)
32/// - `platform_version` - Protocol version
33///
34/// The fee is not a parameter: consensus always charges exactly
35/// `compute_shielded_unshield_fee` (the base shielded minimum fee PLUS the flat storage cost of the
36/// single `AddBalanceToAddress` write this transition performs crediting the net to the output
37/// address) and ignores any surplus. Returns the built transition together with the fee (in
38/// credits) that was applied.
39#[allow(clippy::too_many_arguments)]
40pub fn build_unshield_transition<P: OrchardProver>(
41    spends: Vec<SpendableNote>,
42    output_address: PlatformAddress,
43    unshield_amount: u64,
44    change_address: &OrchardAddress,
45    fvk: &FullViewingKey,
46    ask: &SpendAuthorizingKey,
47    anchor: Anchor,
48    prover: &P,
49    memo: [u8; 36],
50    platform_version: &PlatformVersion,
51) -> Result<(StateTransition, Credits), ProtocolError> {
52    if unshield_amount > i64::MAX as u64 {
53        return Err(ProtocolError::ShieldedBuildError(format!(
54            "unshield amount {} exceeds maximum allowed value {}",
55            unshield_amount,
56            i64::MAX as u64
57        )));
58    }
59
60    let total_spent: u64 = spends.iter().map(|s| s.note.value().inner()).sum();
61
62    // Orchard's BundleType::DEFAULT pads every bundle to a 2-action minimum
63    // (MIN_ACTIONS), so even a single-spend unshield is serialized and proven with 2
64    // actions. Price the fee against that same floor (matching shielded_transfer);
65    // otherwise consensus recomputes min_fee from the on-wire actions.len() == 2 and
66    // rejects an honest single-spend unshield with InsufficientShieldedFeeError.
67    let num_actions = spends.len().max(2);
68    // The fee is fixed at the unshield minimum: consensus always carves exactly
69    // `compute_shielded_unshield_fee` from the pool — the base shielded minimum fee PLUS the flat
70    // storage cost of the single `AddBalanceToAddress` write this transition performs — and the net
71    // (`unshield_amount`) is credited to the output address.
72    let fee = compute_shielded_unshield_fee(num_actions, platform_version)?;
73
74    let required = unshield_amount.checked_add(fee).ok_or_else(|| {
75        ProtocolError::ShieldedBuildError("fee + unshield_amount overflows u64".to_string())
76    })?;
77    if required > total_spent {
78        return Err(ProtocolError::ShieldedBuildError(format!(
79            "unshield amount {} + fee {} = {} exceeds total spendable value {}",
80            unshield_amount, fee, required, total_spent
81        )));
82    }
83
84    let change_amount = total_spent - required;
85
86    // Bind the transparent fields (output_address, unshielding_amount == required) into the
87    // Orchard sighash. Shared with the consensus verifier in shielded_proof.rs so the signed
88    // and verified bytes cannot diverge.
89    let extra_sighash_data = crate::shielded::unshield_extra_sighash_data(
90        &output_address.to_bytes(),
91        required,
92        platform_version,
93    )?;
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    let state_transition = 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    Ok((state_transition, fee))
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::shielded::builder::test_helpers::{
125        test_orchard_address, test_spendable_note, TestProver,
126    };
127
128    #[test]
129    fn test_unshield_insufficient_funds() {
130        let platform_version = PlatformVersion::latest();
131        let change_address = test_orchard_address();
132        let output_address = PlatformAddress::P2pkh([1u8; 20]);
133
134        let note = test_spendable_note(100);
135        let spends = vec![note];
136
137        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
138            .expect("valid spending key bytes");
139        let fvk = FullViewingKey::from(&sk);
140        let ask = SpendAuthorizingKey::from(&sk);
141
142        let result = build_unshield_transition(
143            spends,
144            output_address,
145            1_000_000,
146            &change_address,
147            &fvk,
148            &ask,
149            Anchor::empty_tree(),
150            &TestProver,
151            [0u8; 36],
152            platform_version,
153        );
154
155        assert!(result.is_err());
156        let err = result.unwrap_err().to_string();
157        assert!(
158            err.contains("exceeds total spendable value"),
159            "unexpected error: {}",
160            err
161        );
162    }
163
164    // --------------------------------------------------------------
165    // Extra coverage — bounds / overflow / empty-spends branches
166    // --------------------------------------------------------------
167
168    #[test]
169    fn test_unshield_amount_exceeds_i64_max_errors() {
170        let platform_version = PlatformVersion::latest();
171        let change_address = test_orchard_address();
172        let output_address = PlatformAddress::P2pkh([1u8; 20]);
173
174        let note = test_spendable_note(u64::MAX);
175        let spends = vec![note];
176
177        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
178        let fvk = FullViewingKey::from(&sk);
179        let ask = SpendAuthorizingKey::from(&sk);
180
181        let result = build_unshield_transition(
182            spends,
183            output_address,
184            (i64::MAX as u64) + 1, // overflow the i64 cap
185            &change_address,
186            &fvk,
187            &ask,
188            Anchor::empty_tree(),
189            &TestProver,
190            [0u8; 36],
191            platform_version,
192        );
193        assert!(result.is_err());
194        let err = result.unwrap_err().to_string();
195        assert!(
196            err.contains("exceeds maximum allowed value"),
197            "unexpected error: {}",
198            err
199        );
200    }
201
202    #[test]
203    fn test_unshield_amount_exceeds_spendable_with_default_fee() {
204        // unshield_amount + default_min_fee > total_spent should surface
205        // the "exceeds total spendable value" branch.
206        let platform_version = PlatformVersion::latest();
207        let change_address = test_orchard_address();
208        let output_address = PlatformAddress::P2pkh([1u8; 20]);
209
210        let note = test_spendable_note(5_000);
211        let spends = vec![note];
212
213        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
214        let fvk = FullViewingKey::from(&sk);
215        let ask = SpendAuthorizingKey::from(&sk);
216
217        let result = build_unshield_transition(
218            spends,
219            output_address,
220            6_000, // more than the note's 5_000
221            &change_address,
222            &fvk,
223            &ask,
224            Anchor::empty_tree(),
225            &TestProver,
226            [0u8; 36],
227            platform_version,
228        );
229        let err = result.unwrap_err().to_string();
230        assert!(
231            err.contains("exceeds total spendable value"),
232            "unexpected error: {}",
233            err
234        );
235    }
236
237    #[test]
238    fn test_unshield_zero_spends_errors() {
239        let platform_version = PlatformVersion::latest();
240        let change_address = test_orchard_address();
241        let output_address = PlatformAddress::P2pkh([1u8; 20]);
242
243        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
244        let fvk = FullViewingKey::from(&sk);
245        let ask = SpendAuthorizingKey::from(&sk);
246
247        let result = build_unshield_transition(
248            vec![],
249            output_address,
250            1,
251            &change_address,
252            &fvk,
253            &ask,
254            Anchor::empty_tree(),
255            &TestProver,
256            [0u8; 36],
257            platform_version,
258        );
259        assert!(result.is_err());
260        let err = result.unwrap_err().to_string();
261        assert!(
262            err.contains("exceeds total spendable value"),
263            "unexpected error: {}",
264            err
265        );
266    }
267
268    #[test]
269    fn test_unshield_fee_default_sufficient_value_reaches_add_spend() {
270        // When fee=None and total_spent exactly covers (amount + min_fee),
271        // we bypass all amount checks and hit the downstream add_spend
272        // AnchorMismatch. This exercises the default-fee branch.
273        let platform_version = PlatformVersion::latest();
274        let change_address = test_orchard_address();
275        let output_address = PlatformAddress::P2pkh([1u8; 20]);
276
277        let min_fee = crate::shielded::compute_shielded_unshield_fee(2, platform_version)
278            .expect("fee computation should not overflow");
279        let unshield_amount = 42u64;
280        let note = test_spendable_note(unshield_amount + min_fee);
281        let spends = vec![note];
282
283        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
284        let fvk = FullViewingKey::from(&sk);
285        let ask = SpendAuthorizingKey::from(&sk);
286
287        let result = build_unshield_transition(
288            spends,
289            output_address,
290            unshield_amount,
291            &change_address,
292            &fvk,
293            &ask,
294            Anchor::empty_tree(),
295            &TestProver,
296            [0u8; 36],
297            platform_version,
298        );
299        let err_msg = result.unwrap_err().to_string();
300        assert!(
301            err_msg.contains("failed to add spend")
302                || err_msg.contains("anchor")
303                || err_msg.contains("AnchorMismatch"),
304            "expected downstream add_spend error, got: {}",
305            err_msg
306        );
307    }
308}