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}