dpp/shielded/builder/shield_from_asset_lock.rs
1use crate::address_funds::{OrchardAddress, PlatformAddress};
2use crate::prelude::AssetLockProof;
3use crate::shielded::shield_from_asset_lock_extra_sighash_data;
4use crate::state_transition::shield_from_asset_lock_transition::methods::ShieldFromAssetLockTransitionMethodsV0;
5use crate::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition;
6use crate::state_transition::StateTransition;
7use crate::ProtocolError;
8use platform_version::version::PlatformVersion;
9
10use super::{
11 build_output_only_bundle, serialize_authorized_bundle, OrchardProver, SerializedBundle,
12};
13
14/// Builds a ShieldFromAssetLock state transition (core asset lock -> shielded pool).
15///
16/// Like Shield, constructs an output-only Orchard bundle. The funds come from
17/// a core asset lock proof rather than platform address inputs.
18///
19/// # Parameters
20/// - `recipient` - Orchard address to receive the shielded note
21/// - `shield_amount` - Amount of credits to shield (from the asset lock)
22/// - `asset_lock_proof` - Proof that funds are locked on core chain
23/// - `asset_lock_private_key` - Private key for the asset lock (signs the transition)
24/// - `prover` - Orchard prover (holds the Halo 2 proving key)
25/// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload)
26/// - `sender_ovk` - The sender's outgoing viewing key (External scope). With `Some`, the
27/// recipient output's `out_ciphertext` is encrypted under it so the sender can later
28/// recover the sent note (recipient, value, memo) from chain data via OVK recovery —
29/// the Zcash outgoing-transaction-history convention. With `None`, a random outgoing
30/// cipher key is used and the sent note is unrecoverable by anyone.
31/// - `surplus_output` - Optional platform address that receives the asset-lock surplus
32/// (`asset_lock_value − shield_amount − fee`); when `None`, the surplus is added to the fee
33/// pools, capped at `shielded_implicit_fee_cap`
34/// - `dummy_outputs` - Number of extra zero-value anonymity-set filler outputs to append after
35/// the real recipient output (unrecoverable random addresses, `None` OVK, empty memo). `0`
36/// reproduces the historical single-output bundle exactly. The on-wire action count becomes
37/// `max(1 + dummy_outputs, 2)`, which consensus prices the fee from — see the pool-seeding flow.
38/// - `platform_version` - Protocol version
39#[allow(clippy::too_many_arguments)]
40pub fn build_shield_from_asset_lock_transition<P: OrchardProver>(
41 recipient: &OrchardAddress,
42 shield_amount: u64,
43 asset_lock_proof: AssetLockProof,
44 asset_lock_private_key: &[u8],
45 prover: &P,
46 memo: [u8; 36],
47 sender_ovk: Option<grovedb_commitment_tree::OutgoingViewingKey>,
48 surplus_output: Option<PlatformAddress>,
49 dummy_outputs: usize,
50 platform_version: &PlatformVersion,
51) -> Result<StateTransition, ProtocolError> {
52 let (sb, value_balance) = build_bound_bundle(
53 recipient,
54 shield_amount,
55 &asset_lock_proof,
56 prover,
57 memo,
58 sender_ovk,
59 dummy_outputs,
60 platform_version,
61 )?;
62
63 ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle(
64 asset_lock_proof,
65 asset_lock_private_key,
66 sb.actions,
67 value_balance,
68 sb.anchor,
69 sb.proof,
70 sb.binding_signature,
71 surplus_output,
72 platform_version,
73 )
74}
75
76/// Builds a ShieldFromAssetLock state transition where the
77/// asset-lock-proof signature is produced by an external
78/// [`key_wallet::signer::Signer`] (Swift / hardware-wallet / HSM
79/// flow). The raw private key never crosses the FFI boundary;
80/// derive + sign + zeroise happen inside the signer.
81///
82/// # Parameters
83/// - `recipient` - Orchard address to receive the shielded note
84/// - `shield_amount` - Amount of credits to shield (from the asset lock)
85/// - `asset_lock_proof` - Proof that funds are locked on core chain
86/// - `asset_lock_proof_path` - BIP32 path to the asset-lock key inside `asset_lock_signer`
87/// - `asset_lock_signer` - External signer that produces the outer ECDSA signature
88/// - `prover` - Orchard prover (holds the Halo 2 proving key)
89/// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload)
90/// - `sender_ovk` - The sender's outgoing viewing key (External scope). With `Some`, the
91/// recipient output's `out_ciphertext` is encrypted under it so the sender can later
92/// recover the sent note (recipient, value, memo) from chain data via OVK recovery —
93/// the Zcash outgoing-transaction-history convention. With `None`, a random outgoing
94/// cipher key is used and the sent note is unrecoverable by anyone.
95/// - `surplus_output` - Optional platform address that receives the asset-lock surplus
96/// (`asset_lock_value − shield_amount − fee`); when `None`, the surplus is added to the fee
97/// pools, capped at `shielded_implicit_fee_cap`
98/// - `dummy_outputs` - Number of extra zero-value anonymity-set filler outputs to append after
99/// the real recipient output (unrecoverable random addresses, `None` OVK, empty memo). `0`
100/// reproduces the historical single-output bundle exactly. The on-wire action count becomes
101/// `max(1 + dummy_outputs, 2)`, which consensus prices the fee from — see the pool-seeding flow.
102/// - `platform_version` - Protocol version
103#[cfg(feature = "core_key_wallet")]
104#[allow(clippy::too_many_arguments)]
105pub async fn build_shield_from_asset_lock_transition_with_signer<P, AS>(
106 recipient: &OrchardAddress,
107 shield_amount: u64,
108 asset_lock_proof: AssetLockProof,
109 asset_lock_proof_path: &::key_wallet::bip32::DerivationPath,
110 asset_lock_signer: &AS,
111 prover: &P,
112 memo: [u8; 36],
113 sender_ovk: Option<grovedb_commitment_tree::OutgoingViewingKey>,
114 surplus_output: Option<PlatformAddress>,
115 dummy_outputs: usize,
116 platform_version: &PlatformVersion,
117) -> Result<StateTransition, ProtocolError>
118where
119 P: OrchardProver,
120 AS: ::key_wallet::signer::Signer,
121{
122 let (sb, value_balance) = build_bound_bundle(
123 recipient,
124 shield_amount,
125 &asset_lock_proof,
126 prover,
127 memo,
128 sender_ovk,
129 dummy_outputs,
130 platform_version,
131 )?;
132
133 ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle_and_signer(
134 asset_lock_proof,
135 asset_lock_proof_path,
136 asset_lock_signer,
137 sb.actions,
138 value_balance,
139 sb.anchor,
140 sb.proof,
141 sb.binding_signature,
142 surplus_output,
143 platform_version,
144 )
145 .await
146}
147
148/// Proves the outputs-only bundle of a `ShieldFromAssetLock` funded by `asset_lock_proof` and
149/// returns it with the amount it moves into the pool. Both builders go through here, so a client
150/// signing with a raw key and one signing through an external signer bind the same preimage.
151#[allow(clippy::too_many_arguments)]
152fn build_bound_bundle<P: OrchardProver>(
153 recipient: &OrchardAddress,
154 shield_amount: u64,
155 asset_lock_proof: &AssetLockProof,
156 prover: &P,
157 memo: [u8; 36],
158 sender_ovk: Option<grovedb_commitment_tree::OutgoingViewingKey>,
159 dummy_outputs: usize,
160 platform_version: &PlatformVersion,
161) -> Result<(SerializedBundle, u64), ProtocolError> {
162 // Bound to the funding asset lock, so nobody can re-wrap the proved bundle around a lock
163 // of their own; empty at protocol versions whose verifier predates the binding.
164 let extra_sighash_data =
165 shield_from_asset_lock_extra_sighash_data(asset_lock_proof, platform_version)?;
166 let bundle = build_output_only_bundle(
167 recipient,
168 shield_amount,
169 memo,
170 sender_ovk,
171 dummy_outputs,
172 &extra_sighash_data,
173 prover,
174 )?;
175 let sb = serialize_authorized_bundle(&bundle);
176
177 // For output-only bundles, Orchard value_balance is negative (value flowing in).
178 // Convert to u64 (absolute amount entering the pool).
179 let value_balance = sb
180 .value_balance
181 .checked_neg()
182 .and_then(|v| u64::try_from(v).ok())
183 .ok_or_else(|| {
184 ProtocolError::ShieldedBuildError(
185 "shield_from_asset_lock: bundle value_balance is not negative".to_string(),
186 )
187 })?;
188 Ok((sb, value_balance))
189}
190
191#[cfg(test)]
192mod tests {
193 use super::super::{build_output_only_bundle, serialize_authorized_bundle};
194 use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver};
195
196 /// Verifies that an output-only bundle produces a negative value_balance
197 /// (value flowing into the pool), which is the precondition for
198 /// shield_from_asset_lock's value_balance conversion.
199 #[test]
200 fn test_output_only_bundle_value_balance_is_negative() {
201 let recipient = test_orchard_address();
202 let amount = 50_000u64;
203
204 let bundle =
205 build_output_only_bundle(&recipient, amount, [0u8; 36], None, 0, &[], &TestProver)
206 .expect("bundle should build successfully");
207 let sb = serialize_authorized_bundle(&bundle);
208
209 // Output-only bundles have negative value_balance (value entering the pool)
210 assert!(
211 sb.value_balance < 0,
212 "expected negative value_balance, got {}",
213 sb.value_balance
214 );
215
216 // The absolute value should match the shield amount
217 let abs_balance = sb
218 .value_balance
219 .checked_neg()
220 .and_then(|v| u64::try_from(v).ok())
221 .expect("value_balance should be safely negatable");
222 assert_eq!(abs_balance, amount);
223 }
224
225 /// Consensus prices the shielded fee from the on-wire `actions.len()`, and the wallet reserves
226 /// the fee for exactly 2 actions (Orchard's `MIN_ACTIONS`). A single-output, spends-disabled
227 /// bundle must therefore serialize to exactly 2 on-wire actions. If a future Orchard or builder
228 /// change alters that padding, the hardcoded wallet reservation would diverge from what consensus
229 /// charges (a valid client tx would be rejected); this test fails loudly if that invariant breaks.
230 #[test]
231 fn test_output_only_bundle_serializes_to_min_actions() {
232 let recipient = test_orchard_address();
233 let bundle =
234 build_output_only_bundle(&recipient, 50_000u64, [0u8; 36], None, 0, &[], &TestProver)
235 .expect("bundle should build");
236 let sb = serialize_authorized_bundle(&bundle);
237 assert_eq!(
238 sb.actions.len(),
239 2,
240 "single-output shield bundle must pad to exactly 2 on-wire actions"
241 );
242 }
243
244 // -------------------------------------------------------------
245 // Arithmetic edge cases on the value_balance conversion branch
246 // (the `checked_neg().and_then(u64::try_from)` chain).
247 // -------------------------------------------------------------
248
249 #[test]
250 fn test_value_balance_positive_would_fail_conversion() {
251 // This is a regression-guard: if a *positive* value_balance ever
252 // reached the conversion path, `checked_neg` on i64::MIN would
253 // overflow and the `.try_from::<u64>` on a negative value would
254 // fail. We simulate by constructing a hypothetical value_balance
255 // scenario rather than calling the high-level builder (which
256 // requires a real AssetLockProof).
257 let positive: i64 = 123;
258 let converted = positive.checked_neg().and_then(|v| u64::try_from(v).ok());
259 assert!(converted.is_none(), "negative result cannot be u64");
260
261 let zero: i64 = 0;
262 let converted_zero = zero.checked_neg().and_then(|v| u64::try_from(v).ok());
263 assert_eq!(converted_zero, Some(0));
264
265 let negative: i64 = -42;
266 let converted_neg = negative.checked_neg().and_then(|v| u64::try_from(v).ok());
267 assert_eq!(converted_neg, Some(42));
268 }
269
270 #[test]
271 fn test_output_only_various_amounts_negative_balance() {
272 // Try several amounts to ensure the helper consistently produces a
273 // negative value_balance equal in magnitude to the requested amount.
274 for amount in [1u64, 100, 1_000_000, u32::MAX as u64] {
275 let recipient = test_orchard_address();
276 let bundle =
277 build_output_only_bundle(&recipient, amount, [0u8; 36], None, 0, &[], &TestProver)
278 .expect("bundle should build");
279 let sb = serialize_authorized_bundle(&bundle);
280 assert_eq!(
281 sb.value_balance,
282 -(amount as i64),
283 "value_balance mismatch for amount {}",
284 amount
285 );
286 }
287 }
288}