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}