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#[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 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 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 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 let bundle = prove_and_sign_bundle(builder, prover, std::slice::from_ref(ask), &[])?;
124 let sb = serialize_authorized_bundle(&bundle);
125
126 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), 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 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 #[test]
224 fn test_shielded_transfer_fee_above_upper_bound() {
225 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 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 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 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), 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 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 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 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}