Skip to main content

dpp/shielded/builder/
shielded_transfer.rs

1use grovedb_commitment_tree::{
2    Anchor, Builder, BundleType, DashMemo, FullViewingKey, NoteValue, PaymentAddress,
3    SpendAuthorizingKey,
4};
5
6use crate::address_funds::OrchardAddress;
7use crate::fee::Credits;
8use crate::shielded::compute_minimum_shielded_fee;
9use crate::state_transition::shielded_transfer_transition::methods::ShieldedTransferTransitionMethodsV0;
10use crate::state_transition::shielded_transfer_transition::ShieldedTransferTransition;
11use crate::state_transition::StateTransition;
12use crate::ProtocolError;
13use platform_version::version::PlatformVersion;
14
15use super::{prove_and_sign_bundle, serialize_authorized_bundle, OrchardProver, SpendableNote};
16
17/// Builds a ShieldedTransfer state transition (shielded pool -> shielded pool).
18///
19/// Spends existing notes and creates a new note for the recipient. The shielded
20/// fee is deducted from the spent notes. Any remaining change is returned to
21/// the `change_address`.
22///
23/// # Parameters
24/// - `spends` - Notes to spend with their Merkle paths
25/// - `recipient` - Orchard address to receive the transferred note
26/// - `transfer_amount` - Amount to transfer to the recipient
27/// - `change_address` - Orchard address for change output (if any)
28/// - `fvk` - Full viewing key for spend authorization
29/// - `ask` - Spend authorizing key for RedPallas signatures
30/// - `anchor` - Sinsemilla root of the note commitment tree (Orchard Anchor)
31/// - `prover` - Orchard prover (holds the Halo 2 proving key)
32/// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload)
33/// - `fee` - Optional fee override; if `None`, the minimum fee is computed automatically.
34///   If `Some`, must be >= the minimum fee.
35/// - `platform_version` - Protocol version
36#[allow(clippy::too_many_arguments)]
37pub fn build_shielded_transfer_transition<P: OrchardProver>(
38    spends: Vec<SpendableNote>,
39    recipient: &OrchardAddress,
40    transfer_amount: u64,
41    change_address: &OrchardAddress,
42    fvk: &FullViewingKey,
43    ask: &SpendAuthorizingKey,
44    anchor: Anchor,
45    prover: &P,
46    memo: [u8; 36],
47    fee: Option<Credits>,
48    platform_version: &PlatformVersion,
49) -> Result<StateTransition, ProtocolError> {
50    let total_spent: u64 = spends.iter().map(|s| s.note.value().inner()).sum();
51
52    // Conservative action count: at least (spends, 2) since we always have
53    // a recipient output and likely a change output.
54    let num_actions = spends.len().max(2);
55    let min_fee = compute_minimum_shielded_fee(num_actions, platform_version);
56    let effective_fee = match fee {
57        Some(f) if f < min_fee => {
58            return Err(ProtocolError::ShieldedBuildError(format!(
59                "fee {} is below minimum required fee {}",
60                f, min_fee
61            )));
62        }
63        Some(f) if f > min_fee.saturating_mul(1000) => {
64            return Err(ProtocolError::ShieldedBuildError(format!(
65                "fee {} exceeds 1000x the minimum fee {}",
66                f, min_fee
67            )));
68        }
69        Some(f) => f,
70        None => min_fee,
71    };
72
73    let required = transfer_amount.checked_add(effective_fee).ok_or_else(|| {
74        ProtocolError::ShieldedBuildError("fee + transfer_amount overflows u64".to_string())
75    })?;
76    if required > total_spent {
77        return Err(ProtocolError::ShieldedBuildError(format!(
78            "transfer amount {} + fee {} = {} exceeds total spendable value {}",
79            transfer_amount, effective_fee, required, total_spent
80        )));
81    }
82
83    let change_amount = total_spent - required;
84
85    let recipient_payment = PaymentAddress::from(recipient);
86
87    let mut builder = Builder::<DashMemo>::new(BundleType::DEFAULT, anchor);
88
89    for spend in spends {
90        builder
91            .add_spend(fvk.clone(), spend.note, spend.merkle_path)
92            .map_err(|e| {
93                ProtocolError::ShieldedBuildError(format!("failed to add spend: {:?}", e))
94            })?;
95    }
96
97    // Primary output to recipient
98    builder
99        .add_output(
100            None,
101            recipient_payment,
102            NoteValue::from_raw(transfer_amount),
103            memo,
104        )
105        .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to add output: {:?}", e)))?;
106
107    // Change output (if any)
108    if change_amount > 0 {
109        let change_payment = PaymentAddress::from(change_address);
110        builder
111            .add_output(
112                None,
113                change_payment,
114                NoteValue::from_raw(change_amount),
115                [0u8; 36],
116            )
117            .map_err(|e| {
118                ProtocolError::ShieldedBuildError(format!("failed to add change output: {:?}", e))
119            })?;
120    }
121
122    // ShieldedTransfer has no extra_data in sighash
123    let bundle = prove_and_sign_bundle(builder, prover, std::slice::from_ref(ask), &[])?;
124    let sb = serialize_authorized_bundle(&bundle);
125
126    // value_balance = effective_fee (the amount leaving the shielded pool as fee)
127    ShieldedTransferTransition::try_from_bundle(
128        sb.actions,
129        sb.value_balance as u64,
130        sb.anchor,
131        sb.proof,
132        sb.binding_signature,
133        platform_version,
134    )
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::shielded::builder::test_helpers::{
141        test_orchard_address, test_spendable_note, TestProver,
142    };
143
144    #[test]
145    fn test_shielded_transfer_fee_below_minimum() {
146        let platform_version = PlatformVersion::latest();
147        let recipient = test_orchard_address();
148        let change_address = test_orchard_address();
149
150        let note = test_spendable_note(1_000_000);
151        let spends = vec![note];
152
153        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
154            .expect("valid spending key bytes");
155        let fvk = FullViewingKey::from(&sk);
156        let ask = SpendAuthorizingKey::from(&sk);
157
158        let result = build_shielded_transfer_transition(
159            spends,
160            &recipient,
161            100,
162            &change_address,
163            &fvk,
164            &ask,
165            Anchor::empty_tree(),
166            &TestProver,
167            [0u8; 36],
168            Some(1), // fee = 1, should be below minimum
169            platform_version,
170        );
171
172        assert!(result.is_err());
173        let err = result.unwrap_err().to_string();
174        assert!(
175            err.contains("below minimum required fee"),
176            "unexpected error: {}",
177            err
178        );
179    }
180
181    #[test]
182    fn test_shielded_transfer_insufficient_funds() {
183        let platform_version = PlatformVersion::latest();
184        let recipient = test_orchard_address();
185        let change_address = test_orchard_address();
186
187        // Note with only 100 credits
188        let note = test_spendable_note(100);
189        let spends = vec![note];
190
191        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
192            .expect("valid spending key bytes");
193        let fvk = FullViewingKey::from(&sk);
194        let ask = SpendAuthorizingKey::from(&sk);
195
196        let result = build_shielded_transfer_transition(
197            spends,
198            &recipient,
199            1_000_000,
200            &change_address,
201            &fvk,
202            &ask,
203            Anchor::empty_tree(),
204            &TestProver,
205            [0u8; 36],
206            None,
207            platform_version,
208        );
209
210        assert!(result.is_err());
211        let err = result.unwrap_err().to_string();
212        assert!(
213            err.contains("exceeds total spendable value"),
214            "unexpected error: {}",
215            err
216        );
217    }
218
219    // --------------------------------------------------------------
220    // Extra coverage — error/overflow branches
221    // --------------------------------------------------------------
222
223    #[test]
224    fn test_shielded_transfer_fee_above_upper_bound() {
225        // Fee > 1000x the minimum fee should be rejected.
226        let platform_version = PlatformVersion::latest();
227        let recipient = test_orchard_address();
228        let change_address = test_orchard_address();
229
230        let note = test_spendable_note(u64::MAX);
231        let spends = vec![note];
232
233        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
234            .expect("valid spending key bytes");
235        let fvk = FullViewingKey::from(&sk);
236        let ask = SpendAuthorizingKey::from(&sk);
237
238        // num_actions is max(spends.len(), 2) = 2.
239        let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version);
240        let excessive_fee = min_fee.saturating_mul(1000) + 1;
241
242        let result = build_shielded_transfer_transition(
243            spends,
244            &recipient,
245            10,
246            &change_address,
247            &fvk,
248            &ask,
249            Anchor::empty_tree(),
250            &TestProver,
251            [0u8; 36],
252            Some(excessive_fee),
253            platform_version,
254        );
255
256        assert!(result.is_err());
257        let err = result.unwrap_err().to_string();
258        assert!(
259            err.contains("exceeds 1000x the minimum fee"),
260            "unexpected error: {}",
261            err
262        );
263    }
264
265    #[test]
266    fn test_shielded_transfer_fee_plus_amount_overflow_errors() {
267        // transfer_amount + fee overflows u64 → dedicated error branch.
268        let platform_version = PlatformVersion::latest();
269        let recipient = test_orchard_address();
270        let change_address = test_orchard_address();
271
272        let note = test_spendable_note(u64::MAX);
273        let spends = vec![note];
274
275        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
276            .expect("valid spending key bytes");
277        let fvk = FullViewingKey::from(&sk);
278        let ask = SpendAuthorizingKey::from(&sk);
279
280        // Compute min fee, then craft a fee that lies in [min_fee, 1000*min_fee]
281        // so we bypass the boundary checks, then pick transfer_amount = u64::MAX
282        // so amount + fee overflows.
283        let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version);
284
285        let result = build_shielded_transfer_transition(
286            spends,
287            &recipient,
288            u64::MAX,
289            &change_address,
290            &fvk,
291            &ask,
292            Anchor::empty_tree(),
293            &TestProver,
294            [0u8; 36],
295            Some(min_fee), // within [min, 1000*min]
296            platform_version,
297        );
298
299        assert!(result.is_err(), "overflow case should error");
300        let err = result.unwrap_err().to_string();
301        assert!(
302            err.contains("fee + transfer_amount overflows u64"),
303            "expected checked_add overflow branch, got: {}",
304            err
305        );
306    }
307
308    #[test]
309    fn test_shielded_transfer_zero_spends_total_is_zero_errors() {
310        // Empty spends → total_spent = 0. Any non-zero transfer will fail
311        // with "exceeds total spendable value". This exercises the
312        // `num_actions = max(0, 2) = 2` branch.
313        let platform_version = PlatformVersion::latest();
314        let recipient = test_orchard_address();
315        let change_address = test_orchard_address();
316
317        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
318        let fvk = FullViewingKey::from(&sk);
319        let ask = SpendAuthorizingKey::from(&sk);
320
321        let result = build_shielded_transfer_transition(
322            vec![],
323            &recipient,
324            1,
325            &change_address,
326            &fvk,
327            &ask,
328            Anchor::empty_tree(),
329            &TestProver,
330            [0u8; 36],
331            None,
332            platform_version,
333        );
334        assert!(result.is_err());
335        let err = result.unwrap_err().to_string();
336        assert!(
337            err.contains("exceeds total spendable value"),
338            "unexpected error: {}",
339            err
340        );
341    }
342
343    #[test]
344    fn test_shielded_transfer_fee_default_is_min_fee() {
345        // When fee is None, the default min fee is computed — verify that a
346        // note *exactly* equal to `transfer_amount + min_fee` on the default
347        // branch does not spuriously fail the "exceeds total" check (it
348        // fails later in add_spend due to anchor mismatch).
349        let platform_version = PlatformVersion::latest();
350        let recipient = test_orchard_address();
351        let change_address = test_orchard_address();
352
353        let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version);
354        let transfer_amount = 10u64;
355        let note = test_spendable_note(transfer_amount + min_fee);
356        let spends = vec![note];
357
358        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
359        let fvk = FullViewingKey::from(&sk);
360        let ask = SpendAuthorizingKey::from(&sk);
361
362        let result = build_shielded_transfer_transition(
363            spends,
364            &recipient,
365            transfer_amount,
366            &change_address,
367            &fvk,
368            &ask,
369            Anchor::empty_tree(),
370            &TestProver,
371            [0u8; 36],
372            None,
373            platform_version,
374        );
375
376        // With a valid fee/amount relationship, the builder proceeds past
377        // the amount checks and hits the add_spend AnchorMismatch.
378        let err_msg = result.unwrap_err().to_string();
379        assert!(
380            err_msg.contains("failed to add spend")
381                || err_msg.contains("anchor")
382                || err_msg.contains("AnchorMismatch"),
383            "expected downstream add_spend error, got: {}",
384            err_msg
385        );
386    }
387}