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_shielded_withdrawal_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`; the change note is encrypted
21/// with the sender's External-scope OVK (derived from `fvk`) so the wallet
22/// can recover it — including the structured memo — via OVK recovery.
23///
24/// # Parameters
25/// - `spends` - Notes to spend with their Merkle paths
26/// - `withdrawal_amount` - Amount to withdraw to the core chain
27/// - `output_script` - Core chain script to receive the funds
28/// - `core_fee_per_byte` - Core chain fee rate
29/// - `pooling` - Withdrawal pooling strategy
30/// - `change_address` - Orchard address for change output
31/// - `fvk` - Full viewing key for spend authorization
32/// - `ask` - Spend authorizing key for RedPallas signatures
33/// - `anchor` - Sinsemilla root of the note commitment tree (Orchard Anchor)
34/// - `prover` - Orchard prover (holds the Halo 2 proving key)
35/// - `memo` - 36-byte structured memo for the change output (4-byte type tag + 32-byte payload)
36/// - `platform_version` - Protocol version
37///
38/// The fee is not a parameter: consensus always charges exactly
39/// `compute_shielded_withdrawal_fee` (the base shielded minimum fee PLUS the flat storage cost of
40/// the Core withdrawal document this transition inserts) and ignores any surplus. Returns the built
41/// transition together with the fee (in credits) that was applied.
42#[allow(clippy::too_many_arguments)]
43pub fn build_shielded_withdrawal_transition<P: OrchardProver>(
44    spends: Vec<SpendableNote>,
45    withdrawal_amount: u64,
46    output_script: CoreScript,
47    core_fee_per_byte: u32,
48    pooling: Pooling,
49    change_address: &OrchardAddress,
50    fvk: &FullViewingKey,
51    ask: &SpendAuthorizingKey,
52    anchor: Anchor,
53    prover: &P,
54    memo: [u8; 36],
55    platform_version: &PlatformVersion,
56) -> Result<(StateTransition, Credits), ProtocolError> {
57    if withdrawal_amount > i64::MAX as u64 {
58        return Err(ProtocolError::ShieldedBuildError(format!(
59            "withdrawal amount {} exceeds maximum allowed value {}",
60            withdrawal_amount,
61            i64::MAX as u64
62        )));
63    }
64
65    let total_spent: u64 = spends.iter().map(|s| s.note.value().inner()).sum();
66
67    // Orchard's BundleType::DEFAULT pads every bundle to a 2-action minimum
68    // (MIN_ACTIONS), so even a single-spend withdrawal is serialized and proven with 2
69    // actions. Price the fee against that same floor (matching shielded_transfer);
70    // otherwise consensus recomputes min_fee from the on-wire actions.len() == 2 and
71    // rejects an honest single-spend withdrawal with InsufficientShieldedFeeError (or,
72    // post-fee, WithdrawalBelowMinAmountError).
73    let num_actions = spends.len().max(2);
74    // The fee is fixed at the withdrawal minimum: consensus always carves exactly
75    // `compute_shielded_withdrawal_fee` from the pool — the base shielded minimum fee PLUS the
76    // flat storage cost of the Core withdrawal document this transition inserts — and the net
77    // (`withdrawal_amount`) goes to that Core withdrawal document.
78    let fee = compute_shielded_withdrawal_fee(num_actions, platform_version)?;
79
80    let required = withdrawal_amount.checked_add(fee).ok_or_else(|| {
81        ProtocolError::ShieldedBuildError("fee + withdrawal_amount overflows u64".to_string())
82    })?;
83    if required > total_spent {
84        return Err(ProtocolError::ShieldedBuildError(format!(
85            "withdrawal amount {} + fee {} = {} exceeds total spendable value {}",
86            withdrawal_amount, fee, required, total_spent
87        )));
88    }
89
90    let change_amount = total_spent - required;
91
92    // Bind every Core-facing withdrawal field into the Orchard sighash (output_script,
93    // unshielding_amount == required, core_fee_per_byte, pooling) so the binding signature
94    // authorizes them. Shared with the consensus verifier in shielded_proof.rs.
95    let extra_sighash_data = crate::shielded::shielded_withdrawal_extra_sighash_data(
96        output_script.as_bytes(),
97        required,
98        core_fee_per_byte,
99        pooling,
100        platform_version,
101    )?;
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    let state_transition = 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    Ok((state_transition, fee))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::shielded::builder::test_helpers::{
135        test_orchard_address, test_spendable_note, TestProver,
136    };
137
138    #[test]
139    fn test_shielded_withdrawal_insufficient_funds() {
140        let platform_version = PlatformVersion::latest();
141        let change_address = test_orchard_address();
142
143        let note = test_spendable_note(100);
144        let spends = vec![note];
145
146        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
147            .expect("valid spending key bytes");
148        let fvk = FullViewingKey::from(&sk);
149        let ask = SpendAuthorizingKey::from(&sk);
150
151        let result = build_shielded_withdrawal_transition(
152            spends,
153            1_000_000,
154            CoreScript::new_p2pkh([1u8; 20]),
155            1,
156            Pooling::Never,
157            &change_address,
158            &fvk,
159            &ask,
160            Anchor::empty_tree(),
161            &TestProver,
162            [0u8; 36],
163            platform_version,
164        );
165
166        assert!(result.is_err());
167        let err = result.unwrap_err().to_string();
168        assert!(
169            err.contains("exceeds total spendable value"),
170            "unexpected error: {}",
171            err
172        );
173    }
174
175    // --------------------------------------------------------------
176    // Extra coverage — upper-bound / overflow / default branches
177    // --------------------------------------------------------------
178
179    #[test]
180    fn test_shielded_withdrawal_amount_exceeds_i64_max_errors() {
181        // `withdrawal_amount > i64::MAX as u64` is the first check and has
182        // its own error branch.
183        let platform_version = PlatformVersion::latest();
184        let change_address = test_orchard_address();
185
186        let note = test_spendable_note(u64::MAX);
187        let spends = vec![note];
188
189        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
190        let fvk = FullViewingKey::from(&sk);
191        let ask = SpendAuthorizingKey::from(&sk);
192
193        let result = build_shielded_withdrawal_transition(
194            spends,
195            (i64::MAX as u64) + 1, // exceeds the i64 limit
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            platform_version,
206        );
207        assert!(result.is_err());
208        let err = result.unwrap_err().to_string();
209        assert!(
210            err.contains("exceeds maximum allowed value"),
211            "unexpected error: {}",
212            err
213        );
214    }
215
216    #[test]
217    fn test_shielded_withdrawal_zero_spends_errors() {
218        // Empty spends vec → total_spent = 0 and num_actions = 2 (max floor).
219        let platform_version = PlatformVersion::latest();
220        let change_address = test_orchard_address();
221
222        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
223        let fvk = FullViewingKey::from(&sk);
224        let ask = SpendAuthorizingKey::from(&sk);
225
226        let result = build_shielded_withdrawal_transition(
227            vec![],
228            1,
229            CoreScript::new_p2pkh([1u8; 20]),
230            1,
231            Pooling::Never,
232            &change_address,
233            &fvk,
234            &ask,
235            Anchor::empty_tree(),
236            &TestProver,
237            [0u8; 36],
238            platform_version,
239        );
240        assert!(result.is_err());
241        let err = result.unwrap_err().to_string();
242        assert!(
243            err.contains("exceeds total spendable value"),
244            "unexpected error: {}",
245            err
246        );
247    }
248}