Skip to main content

dpp/shielded/builder/
shielded_transfer.rs

1use grovedb_commitment_tree::{
2    Anchor, Builder, BundleType, DashMemo, FullViewingKey, NoteValue, PaymentAddress, Scope,
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/// Both real outputs are encrypted with the sender's External-scope OVK
24/// (derived from `fvk`), so the sender can recover its own send history
25/// (recipient, value, memo) from chain data via OVK recovery.
26///
27/// # Parameters
28/// - `spends` - Notes to spend with their Merkle paths
29/// - `recipient` - Orchard address to receive the transferred note
30/// - `transfer_amount` - Amount to transfer to the recipient
31/// - `change_address` - Orchard address for change output (if any)
32/// - `fvk` - Full viewing key for spend authorization
33/// - `ask` - Spend authorizing key for RedPallas signatures
34/// - `anchor` - Sinsemilla root of the note commitment tree (Orchard Anchor)
35/// - `prover` - Orchard prover (holds the Halo 2 proving key)
36/// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload)
37/// - `platform_version` - Protocol version
38///
39/// The fee is not a parameter: a shielded transfer's `value_balance` IS the fee and consensus
40/// pins it to exactly `compute_minimum_shielded_fee`, so there is nothing for the caller to
41/// choose. Returns the built transition together with the fee (in credits) that was applied.
42#[allow(clippy::too_many_arguments)]
43pub fn build_shielded_transfer_transition<P: OrchardProver>(
44    spends: Vec<SpendableNote>,
45    recipient: &OrchardAddress,
46    transfer_amount: u64,
47    change_address: &OrchardAddress,
48    fvk: &FullViewingKey,
49    ask: &SpendAuthorizingKey,
50    anchor: Anchor,
51    prover: &P,
52    memo: [u8; 36],
53    platform_version: &PlatformVersion,
54) -> Result<(StateTransition, Credits), ProtocolError> {
55    let total_spent: u64 = spends.iter().map(|s| s.note.value().inner()).sum();
56
57    // Conservative action count: at least (spends, 2) since we always have
58    // a recipient output and likely a change output.
59    let num_actions = spends.len().max(2);
60    // The fee is fixed at the minimum: a transfer's `value_balance` IS the fee and consensus
61    // pins it to exactly this amount (overpayment buys nothing and would leak a distinguishing
62    // fee fingerprint that breaks shielded uniformity).
63    let fee = compute_minimum_shielded_fee(num_actions, platform_version)?;
64
65    let required = transfer_amount.checked_add(fee).ok_or_else(|| {
66        ProtocolError::ShieldedBuildError("fee + transfer_amount overflows u64".to_string())
67    })?;
68    if required > total_spent {
69        return Err(ProtocolError::ShieldedBuildError(format!(
70            "transfer amount {} + fee {} = {} exceeds total spendable value {}",
71            transfer_amount, fee, required, total_spent
72        )));
73    }
74
75    let change_amount = total_spent - required;
76
77    let recipient_payment = PaymentAddress::from(recipient);
78
79    let mut builder = Builder::<DashMemo>::new(BundleType::DEFAULT, anchor);
80
81    for spend in spends {
82        builder
83            .add_spend(fvk.clone(), spend.note, spend.merkle_path)
84            .map_err(|e| {
85                ProtocolError::ShieldedBuildError(format!("failed to add spend: {:?}", e))
86            })?;
87    }
88
89    // Both real outputs carry an `out_ciphertext` encrypted under the sender's
90    // External-scope OVK (the Zcash outgoing-transaction-history convention),
91    // so the sender can recover its own send history — recipient, value, memo —
92    // from chain data alone. Without it, the outgoing cipher key is random and
93    // the sent note is unrecoverable by anyone, including the sender.
94    let sender_ovk = fvk.to_ovk(Scope::External);
95
96    // Primary output to recipient
97    builder
98        .add_output(
99            Some(sender_ovk.clone()),
100            recipient_payment,
101            NoteValue::from_raw(transfer_amount),
102            memo,
103        )
104        .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to add output: {:?}", e)))?;
105
106    // Change output (if any)
107    if change_amount > 0 {
108        let change_payment = PaymentAddress::from(change_address);
109        builder
110            .add_output(
111                Some(sender_ovk),
112                change_payment,
113                NoteValue::from_raw(change_amount),
114                [0u8; 36],
115            )
116            .map_err(|e| {
117                ProtocolError::ShieldedBuildError(format!("failed to add change output: {:?}", e))
118            })?;
119    }
120
121    // ShieldedTransfer has no extra_data in sighash
122    let bundle = prove_and_sign_bundle(builder, prover, std::slice::from_ref(ask), &[])?;
123    let sb = serialize_authorized_bundle(&bundle);
124
125    // value_balance = fee (the amount leaving the shielded pool as fee)
126    let state_transition = ShieldedTransferTransition::try_from_bundle(
127        sb.actions,
128        sb.value_balance as u64,
129        sb.anchor,
130        sb.proof,
131        sb.binding_signature,
132        platform_version,
133    )?;
134    Ok((state_transition, fee))
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_insufficient_funds() {
146        let platform_version = PlatformVersion::latest();
147        let recipient = test_orchard_address();
148        let change_address = test_orchard_address();
149
150        // Note with only 100 credits
151        let note = test_spendable_note(100);
152        let spends = vec![note];
153
154        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
155            .expect("valid spending key bytes");
156        let fvk = FullViewingKey::from(&sk);
157        let ask = SpendAuthorizingKey::from(&sk);
158
159        let result = build_shielded_transfer_transition(
160            spends,
161            &recipient,
162            1_000_000,
163            &change_address,
164            &fvk,
165            &ask,
166            Anchor::empty_tree(),
167            &TestProver,
168            [0u8; 36],
169            platform_version,
170        );
171
172        assert!(result.is_err());
173        let err = result.unwrap_err().to_string();
174        assert!(
175            err.contains("exceeds total spendable value"),
176            "unexpected error: {}",
177            err
178        );
179    }
180
181    // --------------------------------------------------------------
182    // Extra coverage — error/overflow branches
183    // --------------------------------------------------------------
184
185    #[test]
186    fn test_shielded_transfer_fee_plus_amount_overflow_errors() {
187        // transfer_amount + fee overflows u64 → dedicated error branch.
188        let platform_version = PlatformVersion::latest();
189        let recipient = test_orchard_address();
190        let change_address = test_orchard_address();
191
192        let note = test_spendable_note(u64::MAX);
193        let spends = vec![note];
194
195        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
196            .expect("valid spending key bytes");
197        let fvk = FullViewingKey::from(&sk);
198        let ask = SpendAuthorizingKey::from(&sk);
199
200        // transfer_amount = u64::MAX so amount + the (internally-computed) minimum fee
201        // overflows u64, hitting the checked_add error branch.
202        let result = build_shielded_transfer_transition(
203            spends,
204            &recipient,
205            u64::MAX,
206            &change_address,
207            &fvk,
208            &ask,
209            Anchor::empty_tree(),
210            &TestProver,
211            [0u8; 36],
212            platform_version,
213        );
214
215        assert!(result.is_err(), "overflow case should error");
216        let err = result.unwrap_err().to_string();
217        assert!(
218            err.contains("fee + transfer_amount overflows u64"),
219            "expected checked_add overflow branch, got: {}",
220            err
221        );
222    }
223
224    #[test]
225    fn test_shielded_transfer_zero_spends_total_is_zero_errors() {
226        // Empty spends → total_spent = 0. Any non-zero transfer will fail
227        // with "exceeds total spendable value". This exercises the
228        // `num_actions = max(0, 2) = 2` branch.
229        let platform_version = PlatformVersion::latest();
230        let recipient = test_orchard_address();
231        let change_address = test_orchard_address();
232
233        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
234        let fvk = FullViewingKey::from(&sk);
235        let ask = SpendAuthorizingKey::from(&sk);
236
237        let result = build_shielded_transfer_transition(
238            vec![],
239            &recipient,
240            1,
241            &change_address,
242            &fvk,
243            &ask,
244            Anchor::empty_tree(),
245            &TestProver,
246            [0u8; 36],
247            platform_version,
248        );
249        assert!(result.is_err());
250        let err = result.unwrap_err().to_string();
251        assert!(
252            err.contains("exceeds total spendable value"),
253            "unexpected error: {}",
254            err
255        );
256    }
257
258    #[test]
259    fn test_shielded_transfer_uses_min_fee() {
260        // The fee is always the minimum. Verify that a note *exactly* equal to
261        // `transfer_amount + min_fee` proceeds past the "exceeds total" check (it then
262        // fails later in add_spend due to anchor mismatch).
263        let platform_version = PlatformVersion::latest();
264        let recipient = test_orchard_address();
265        let change_address = test_orchard_address();
266
267        let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version)
268            .expect("fee computation should not overflow");
269        let transfer_amount = 10u64;
270        let note = test_spendable_note(transfer_amount + min_fee);
271        let spends = vec![note];
272
273        let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
274        let fvk = FullViewingKey::from(&sk);
275        let ask = SpendAuthorizingKey::from(&sk);
276
277        let result = build_shielded_transfer_transition(
278            spends,
279            &recipient,
280            transfer_amount,
281            &change_address,
282            &fvk,
283            &ask,
284            Anchor::empty_tree(),
285            &TestProver,
286            [0u8; 36],
287            platform_version,
288        );
289
290        // With a valid fee/amount relationship, the builder proceeds past
291        // the amount checks and hits the add_spend AnchorMismatch.
292        let err_msg = result.unwrap_err().to_string();
293        assert!(
294            err_msg.contains("failed to add spend")
295                || err_msg.contains("anchor")
296                || err_msg.contains("AnchorMismatch"),
297            "expected downstream add_spend error, got: {}",
298            err_msg
299        );
300    }
301}