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