Skip to main content

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}