Skip to main content

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}