Skip to main content

dpp/shielded/builder/
mod.rs

1//! Convenience builders for constructing shielded state transitions.
2//!
3//! These functions encapsulate the full Orchard bundle construction pipeline:
4//! builder configuration, proof generation, signature application,
5//! and serialization into platform state transitions.
6//!
7//! Requires the `shielded-client` feature, which pulls in
8//! `grovedb-commitment-tree` (and transitively the `orchard` crate).
9//!
10//! # Example
11//!
12//! ```ignore
13//! use dpp::shielded::builder::*;
14//! use grovedb_commitment_tree::{SpendingKey, FullViewingKey, Scope, ProvingKey};
15//!
16//! // Derive recipient address
17//! let sk = SpendingKey::from_bytes(seed)?;
18//! let fvk = FullViewingKey::from(&sk);
19//! let recipient = OrchardAddress::from_raw_bytes(
20//!     &fvk.address_at(0, Scope::External).to_raw_address_bytes(),
21//! );
22//!
23//! // Build a shield transition; pass the sender's OVK so the wallet can
24//! // later recover its own send from chain data (None = unrecoverable)
25//! let pk = ProvingKey::build();
26//! let st = build_shield_transition(
27//!     &recipient, shield_amount, inputs, fee_strategy,
28//!     &signer, 0, &pk, [0u8; 36], Some(fvk.to_ovk(Scope::External)), platform_version,
29//! )?;
30//! ```
31
32mod identity_create_from_shielded_pool;
33mod identity_top_up_from_shielded_pool;
34mod shield;
35mod shield_from_asset_lock;
36mod shield_from_identity;
37mod shielded_transfer;
38mod shielded_withdrawal;
39mod unshield;
40
41pub use self::shield::build_shield_transition;
42pub use identity_create_from_shielded_pool::{
43    build_identity_create_from_shielded_pool_transition, IdentityCreateFromShieldedPoolBuildResult,
44};
45pub use identity_top_up_from_shielded_pool::build_identity_top_up_from_shielded_pool_transition;
46pub use shield_from_asset_lock::build_shield_from_asset_lock_transition;
47#[cfg(feature = "core_key_wallet")]
48pub use shield_from_asset_lock::build_shield_from_asset_lock_transition_with_signer;
49pub use shield_from_identity::build_shield_from_identity_transition;
50pub use shielded_transfer::build_shielded_transfer_transition;
51pub use shielded_withdrawal::build_shielded_withdrawal_transition;
52pub use unshield::build_unshield_transition;
53
54use grovedb_commitment_tree::{
55    Anchor, Authorized, Builder, Bundle, BundleType, DashMemo, Flags as OrchardFlags,
56    FullViewingKey, MerklePath, Note, NoteValue, OutgoingViewingKey, PaymentAddress, ProvingKey,
57    Scope, SpendAuthorizingKey, SpendingKey,
58};
59use rand::rngs::OsRng;
60use rand::RngCore;
61
62use crate::address_funds::OrchardAddress;
63use crate::shielded::{compute_platform_sighash, SerializedAction};
64use crate::ProtocolError;
65
66/// Trait abstracting over Orchard proof generation.
67///
68/// This follows the same pattern as `Signer` — callers provide an implementation
69/// that holds (and potentially caches) the expensive `ProvingKey`, and the builder
70/// functions use it via this trait.
71pub trait OrchardProver {
72    /// Returns a reference to the Halo 2 proving key for the Orchard circuit.
73    fn proving_key(&self) -> &ProvingKey;
74}
75
76/// A note that can be spent in a shielded transaction, paired with its
77/// Merkle inclusion path in the commitment tree.
78pub struct SpendableNote {
79    /// The Orchard note to spend.
80    pub note: Note,
81    /// Merkle path proving the note's commitment exists in the tree.
82    pub merkle_path: MerklePath,
83}
84
85/// The serialized fields extracted from an authorized Orchard bundle,
86/// ready for use by state transition constructors.
87pub struct SerializedBundle {
88    /// Serialized Orchard actions (spends + outputs).
89    pub actions: Vec<SerializedAction>,
90    /// Bundle flags byte.
91    pub flags: u8,
92    /// Net value balance (positive = value leaving the shielded pool).
93    pub value_balance: i64,
94    /// Sinsemilla root of the Orchard note commitment tree (32 bytes).
95    /// This is the Orchard `Anchor` — the root hash of the depth-32 Sinsemilla
96    /// Merkle tree over extracted note commitments (cmx values).
97    pub anchor: [u8; 32],
98    /// Halo 2 proof bytes.
99    pub proof: Vec<u8>,
100    /// Binding signature (64 bytes).
101    pub binding_signature: [u8; 64],
102}
103
104impl From<&OrchardAddress> for PaymentAddress {
105    fn from(address: &OrchardAddress) -> Self {
106        *address.inner()
107    }
108}
109
110/// Serializes an authorized Orchard bundle into the raw fields used by
111/// state transition constructors.
112pub fn serialize_authorized_bundle(bundle: &Bundle<Authorized, i64, DashMemo>) -> SerializedBundle {
113    let actions: Vec<SerializedAction> = bundle
114        .actions()
115        .iter()
116        .map(|action| {
117            let enc = action.encrypted_note();
118            let mut encrypted_note = Vec::with_capacity(216);
119            encrypted_note.extend_from_slice(&enc.epk_bytes);
120            encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref());
121            encrypted_note.extend_from_slice(&enc.out_ciphertext);
122            SerializedAction {
123                nullifier: action.nullifier().to_bytes(),
124                rk: <[u8; 32]>::from(action.rk()),
125                cmx: action.cmx().to_bytes(),
126                encrypted_note,
127                cv_net: action.cv_net().to_bytes(),
128                spend_auth_sig: <[u8; 64]>::from(action.authorization()),
129            }
130        })
131        .collect();
132    let flags = bundle.flags().to_byte();
133    let value_balance = *bundle.value_balance();
134    let anchor = bundle.anchor().to_bytes();
135    let proof = bundle.authorization().proof().as_ref().to_vec();
136    let binding_signature = <[u8; 64]>::from(bundle.authorization().binding_signature());
137    SerializedBundle {
138        actions,
139        flags,
140        value_balance,
141        anchor,
142        proof,
143        binding_signature,
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Internal helpers
149// ---------------------------------------------------------------------------
150
151/// Generates a fresh random Orchard payment address with no recoverable
152/// spending authority retained by anyone.
153///
154/// Draws 32 random bytes for an Orchard `SpendingKey` (retrying on the
155/// rare invalid draw — `SpendingKey::from_bytes` returns a `CtOption`),
156/// derives its `FullViewingKey`, and returns the External-scope address
157/// at diversifier index 0. The spending key is dropped here, so the
158/// resulting address is unspendable by this process — exactly what a
159/// zero-value anonymity-set filler output wants.
160fn random_orchard_payment_address() -> PaymentAddress {
161    let mut rng = OsRng;
162    loop {
163        let mut bytes = [0u8; 32];
164        rng.fill_bytes(&mut bytes);
165        if let Some(sk) = Option::<SpendingKey>::from(SpendingKey::from_bytes(bytes)) {
166            let fvk = FullViewingKey::from(&sk);
167            return fvk.address_at(0u32, Scope::External);
168        }
169    }
170}
171
172/// Builds an output-only Orchard bundle (no spends).
173///
174/// Used by Shield and ShieldFromAssetLock transitions where funds enter
175/// the shielded pool from transparent sources.
176///
177/// `sender_ovk` encrypts the real output's `out_ciphertext` (Zcash
178/// outgoing-transaction-history convention): with `Some`, the sender can
179/// later recover the note (recipient, value, memo) from chain data via
180/// `try_recover_outgoing_note` under that OVK. With `None`, a random
181/// outgoing cipher key is used and the sent note is unrecoverable by
182/// anyone. Orchard's padding outputs always use `None`.
183///
184/// `dummy_outputs` adds that many extra **zero-value** outputs after the
185/// real one, each to a fresh random Orchard address with `sender_ovk =
186/// None` and an empty memo. They are unrecoverable by anyone (no party
187/// holds the spending key) — they exist purely as anonymity-set filler
188/// so a single transition can grow the on-chain note count. With
189/// `dummy_outputs == 0` the bundle is byte-class identical to the
190/// historical single-output form (Orchard still pads to its 2-action
191/// minimum). The on-wire action count is
192/// `max(1 + dummy_outputs, 2)` and the `value_balance` is unchanged
193/// (the dummies contribute zero value).
194pub(crate) fn build_output_only_bundle<P: OrchardProver>(
195    recipient: &OrchardAddress,
196    amount: u64,
197    memo: [u8; 36],
198    sender_ovk: Option<OutgoingViewingKey>,
199    dummy_outputs: usize,
200    prover: &P,
201) -> Result<Bundle<Authorized, i64, DashMemo>, ProtocolError> {
202    let payment_address = PaymentAddress::from(recipient);
203    let anchor = Anchor::empty_tree();
204    let mut builder = Builder::<DashMemo>::new(
205        BundleType::Transactional {
206            flags: OrchardFlags::SPENDS_DISABLED,
207            bundle_required: false,
208        },
209        anchor,
210    );
211
212    builder
213        .add_output(
214            sender_ovk,
215            payment_address,
216            NoteValue::from_raw(amount),
217            memo,
218        )
219        .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to add output: {:?}", e)))?;
220
221    // Anonymity-set filler: zero-value outputs to fresh random addresses,
222    // each with `None` OVK and an empty memo (unrecoverable by anyone).
223    for _ in 0..dummy_outputs {
224        let filler_address = random_orchard_payment_address();
225        builder
226            .add_output(None, filler_address, NoteValue::from_raw(0), [0u8; 36])
227            .map_err(|e| {
228                ProtocolError::ShieldedBuildError(format!("failed to add dummy output: {:?}", e))
229            })?;
230    }
231
232    prove_and_sign_bundle(builder, prover, &[], &[])
233}
234
235/// Builds a spend+output Orchard bundle.
236///
237/// Used by Unshield, ShieldedWithdrawal, and IdentityCreateFromShieldedPool
238/// where funds are spent from existing notes. The single shielded output is
239/// the spender's change note; its `out_ciphertext` is encrypted under the
240/// spender's own External-scope OVK (derived from `fvk`) so the wallet can
241/// recover the note — including its structured memo, which the compact IVK
242/// scan path never sees — from chain data alone.
243#[allow(clippy::too_many_arguments)]
244pub(crate) fn build_spend_bundle<P: OrchardProver>(
245    spends: Vec<SpendableNote>,
246    recipient: &OrchardAddress,
247    output_amount: u64,
248    memo: [u8; 36],
249    fvk: &FullViewingKey,
250    ask: &SpendAuthorizingKey,
251    anchor: Anchor,
252    prover: &P,
253    extra_sighash_data: &[u8],
254) -> Result<Bundle<Authorized, i64, DashMemo>, ProtocolError> {
255    let data = extra_sighash_data.to_vec();
256    build_spend_bundle_with(
257        spends,
258        recipient,
259        output_amount,
260        memo,
261        fvk,
262        ask,
263        anchor,
264        prover,
265        move |_| Ok(data),
266    )
267}
268
269/// Like [`build_spend_bundle`], but the extra sighash data is computed by a
270/// closure that receives the built bundle's published action nullifiers (in
271/// on-wire order, INCLUDING any padding actions' dummy nullifiers).
272///
273/// `IdentityCreateFromShieldedPool` needs this: its identity id is
274/// `double_sha256(sorted published nullifiers)`, and `BundleType::DEFAULT`
275/// pads single-spend bundles with a dummy action whose random nullifier only
276/// exists once the bundle is built — deriving the id from the real spends
277/// alone would diverge from the consensus re-derivation.
278#[allow(clippy::too_many_arguments)]
279pub(crate) fn build_spend_bundle_with<P: OrchardProver, F>(
280    spends: Vec<SpendableNote>,
281    recipient: &OrchardAddress,
282    output_amount: u64,
283    memo: [u8; 36],
284    fvk: &FullViewingKey,
285    ask: &SpendAuthorizingKey,
286    anchor: Anchor,
287    prover: &P,
288    extra_sighash_data: F,
289) -> Result<Bundle<Authorized, i64, DashMemo>, ProtocolError>
290where
291    F: FnOnce(&[[u8; 32]]) -> Result<Vec<u8>, ProtocolError>,
292{
293    let payment_address = PaymentAddress::from(recipient);
294
295    let mut builder = Builder::<DashMemo>::new(BundleType::DEFAULT, anchor);
296
297    for spend in spends {
298        builder
299            .add_spend(fvk.clone(), spend.note, spend.merkle_path)
300            .map_err(|e| {
301                ProtocolError::ShieldedBuildError(format!("failed to add spend: {:?}", e))
302            })?;
303    }
304
305    builder
306        .add_output(
307            Some(fvk.to_ovk(Scope::External)),
308            payment_address,
309            NoteValue::from_raw(output_amount),
310            memo,
311        )
312        .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to add output: {:?}", e)))?;
313
314    prove_and_sign_bundle_with(
315        builder,
316        prover,
317        std::slice::from_ref(ask),
318        extra_sighash_data,
319    )
320}
321
322/// Takes a configured Builder, generates the proof, computes the platform
323/// sighash, and applies signatures.
324pub(crate) fn prove_and_sign_bundle<P: OrchardProver>(
325    builder: Builder<DashMemo>,
326    prover: &P,
327    signing_keys: &[SpendAuthorizingKey],
328    extra_sighash_data: &[u8],
329) -> Result<Bundle<Authorized, i64, DashMemo>, ProtocolError> {
330    let data = extra_sighash_data.to_vec();
331    prove_and_sign_bundle_with(builder, prover, signing_keys, move |_| Ok(data))
332}
333
334/// Like [`prove_and_sign_bundle`], but the extra sighash data is computed by
335/// a closure receiving the built bundle's published action nullifiers (see
336/// [`build_spend_bundle_with`]). The closure runs after `Builder::build`
337/// fixes the action set (padding included) and before the sighash is bound.
338pub(crate) fn prove_and_sign_bundle_with<P: OrchardProver, F>(
339    builder: Builder<DashMemo>,
340    prover: &P,
341    signing_keys: &[SpendAuthorizingKey],
342    extra_sighash_data: F,
343) -> Result<Bundle<Authorized, i64, DashMemo>, ProtocolError>
344where
345    F: FnOnce(&[[u8; 32]]) -> Result<Vec<u8>, ProtocolError>,
346{
347    let mut rng = OsRng;
348
349    let (unauthorized, _) = builder
350        .build::<i64>(&mut rng)
351        .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to build bundle: {:?}", e)))?
352        .ok_or_else(|| {
353            ProtocolError::ShieldedBuildError("bundle was empty after build".to_string())
354        })?;
355
356    let nullifiers: Vec<[u8; 32]> = unauthorized
357        .actions()
358        .iter()
359        .map(|action| action.nullifier().to_bytes())
360        .collect();
361    let extra_sighash_data = extra_sighash_data(&nullifiers)?;
362
363    let bundle_commitment: [u8; 32] = unauthorized.commitment().into();
364    let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data);
365
366    let proven = unauthorized
367        .create_proof(prover.proving_key(), &mut rng)
368        .map_err(|e| {
369            ProtocolError::ShieldedBuildError(format!("failed to create proof: {:?}", e))
370        })?;
371
372    proven
373        .apply_signatures(rng, sighash, signing_keys)
374        .map_err(|e| {
375            ProtocolError::ShieldedBuildError(format!("failed to apply signatures: {:?}", e))
376        })
377}
378
379/// Shared test utilities for builder tests.
380#[cfg(test)]
381pub(crate) mod test_helpers {
382    use super::*;
383    use grovedb_commitment_tree::{
384        FullViewingKey, Hashable, MerkleHashOrchard, Note, NoteValue, ProvingKey, RandomSeed, Rho,
385        Scope, SpendingKey, NOTE_COMMITMENT_TREE_DEPTH,
386    };
387    use std::sync::OnceLock;
388
389    static PROVING_KEY: OnceLock<ProvingKey> = OnceLock::new();
390
391    /// Returns a cached ProvingKey (~30s to build on first call).
392    pub fn proving_key() -> &'static ProvingKey {
393        PROVING_KEY.get_or_init(ProvingKey::build)
394    }
395
396    /// Test implementation of `OrchardProver` backed by the cached proving key.
397    pub struct TestProver;
398
399    impl super::OrchardProver for TestProver {
400        fn proving_key(&self) -> &ProvingKey {
401            proving_key()
402        }
403    }
404
405    /// Creates a test OrchardAddress from a deterministic spending key.
406    pub fn test_orchard_address() -> OrchardAddress {
407        let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key bytes");
408        let fvk = FullViewingKey::from(&sk);
409        let payment_address = fvk.address_at(0u32, Scope::External);
410        OrchardAddress::from_raw_bytes(&payment_address.to_raw_address_bytes())
411            .expect("valid orchard address bytes")
412    }
413
414    /// Creates a SpendableNote with the given value.
415    ///
416    /// The note is cryptographically valid (has a valid commitment) but uses
417    /// an all-zeros Merkle path, so it will only pass the Orchard circuit when
418    /// paired with `Anchor::empty_tree()`. Suitable for both error-path tests
419    /// (where the proving key is never reached) and happy-path tests.
420    pub fn test_spendable_note(value: u64) -> SpendableNote {
421        let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key bytes");
422        let fvk = FullViewingKey::from(&sk);
423        let payment_address = fvk.address_at(0u32, Scope::External);
424
425        // Construct a valid Rho from the zero element (always valid in pallas)
426        let rho: Rho =
427            Option::from(Rho::from_bytes(&[0u8; 32])).expect("zero is valid pallas::Base");
428        let rseed: RandomSeed =
429            Option::from(RandomSeed::from_bytes([1u8; 32], &rho)).expect("valid random seed");
430        let note: Note = Option::from(Note::from_parts(
431            payment_address,
432            NoteValue::from_raw(value),
433            rho,
434            rseed,
435        ))
436        .expect("note commitment should be valid");
437
438        // All-zeros merkle path at position 0 — consistent with Anchor::empty_tree()
439        let auth_path = [MerkleHashOrchard::empty_leaf(); NOTE_COMMITMENT_TREE_DEPTH];
440        let merkle_path = MerklePath::from_parts(0, auth_path);
441
442        SpendableNote { note, merkle_path }
443    }
444}
445
446#[cfg(test)]
447mod mod_tests {
448    use super::test_helpers::{test_orchard_address, test_spendable_note, TestProver};
449    use super::*;
450    use grovedb_commitment_tree::{FullViewingKey, SpendAuthorizingKey, SpendingKey};
451
452    // ------------------------------------------------------------------
453    // `build_output_only_bundle` — exercise the happy path covering the
454    // internal builder configuration and `prove_and_sign_bundle` pipeline
455    // on the empty-signing-keys branch.
456    // ------------------------------------------------------------------
457
458    #[test]
459    fn output_only_bundle_flags_and_value_balance() {
460        let recipient = test_orchard_address();
461        let bundle = build_output_only_bundle(&recipient, 10_000, [0u8; 36], None, 0, &TestProver)
462            .expect("bundle should build");
463
464        // Spends are disabled for Shield / ShieldFromAssetLock bundles.
465        assert!(!bundle.flags().spends_enabled());
466        assert!(bundle.flags().outputs_enabled());
467        // Orchard value_balance is negative when net value enters the pool.
468        assert_eq!(*bundle.value_balance(), -10_000i64);
469        assert!(
470            !bundle.actions().is_empty(),
471            "at least one padding action expected"
472        );
473    }
474
475    // ------------------------------------------------------------------
476    // `build_output_only_bundle` dummy-output padding — the on-wire
477    // action count is `max(1 + dummy_outputs, 2)` (Orchard pads an
478    // output-only bundle to its 2-action minimum) and the dummies are
479    // zero-value, so the bundle's `value_balance` still equals exactly
480    // the real recipient amount. This is the invariant the pool-seeding
481    // flow relies on: one transition publishes up to 6 actions (the most
482    // that fits the 20 KiB transition-size limit), all but one carrying
483    // no value. The cases stop at 5 dummies — the seeding maximum — to
484    // keep this real-proving test inside the CI shielded-step budget
485    // (proof cost grows with the action count).
486    // ------------------------------------------------------------------
487
488    #[test]
489    fn dummy_output_padding_action_count_and_value_balance() {
490        let recipient = test_orchard_address();
491        let amount = 10_000u64;
492
493        // (dummy_outputs, expected on-wire action count).
494        for (dummies, expected_actions) in [(0usize, 2usize), (1, 2), (5, 6)] {
495            let bundle =
496                build_output_only_bundle(&recipient, amount, [0u8; 36], None, dummies, &TestProver)
497                    .expect("bundle should build");
498            assert_eq!(
499                bundle.actions().len(),
500                expected_actions,
501                "dummy_outputs={dummies} should serialize to {expected_actions} actions"
502            );
503            // Dummies are zero-value: net value entering the pool is unchanged.
504            assert_eq!(
505                *bundle.value_balance(),
506                -(amount as i64),
507                "value_balance must equal the real amount regardless of dummy_outputs ({dummies})"
508            );
509        }
510    }
511
512    // ------------------------------------------------------------------
513    // `serialize_authorized_bundle` — verify the mapping from a fully
514    // authorized bundle into the raw state-transition fields.
515    // ------------------------------------------------------------------
516
517    #[test]
518    fn serialize_authorized_bundle_preserves_fields() {
519        let recipient = test_orchard_address();
520        let bundle = build_output_only_bundle(&recipient, 7_777, [3u8; 36], None, 0, &TestProver)
521            .expect("bundle should build");
522        let sb = serialize_authorized_bundle(&bundle);
523
524        assert_eq!(sb.value_balance, *bundle.value_balance());
525        assert_eq!(sb.flags, bundle.flags().to_byte());
526        assert_eq!(sb.anchor, bundle.anchor().to_bytes());
527        assert!(!sb.proof.is_empty(), "Halo 2 proof must not be empty");
528        assert_eq!(sb.binding_signature.len(), 64);
529        assert_eq!(sb.actions.len(), bundle.actions().len());
530        for action in &sb.actions {
531            // Each encrypted_note packs epk (32) + enc_ciphertext (580... wait — 84+512? verify via cap 216)
532            // The explicit layout from serialize_authorized_bundle: epk_bytes (32) +
533            // enc_ciphertext + out_ciphertext = 580 + 80? The code pre-allocates 216.
534            // Don't hardcode length — just verify non-empty and signature sizes.
535            assert!(!action.encrypted_note.is_empty());
536            assert_eq!(action.nullifier.len(), 32);
537            assert_eq!(action.cmx.len(), 32);
538            assert_eq!(action.cv_net.len(), 32);
539            assert_eq!(action.rk.len(), 32);
540            assert_eq!(action.spend_auth_sig.len(), 64);
541        }
542    }
543
544    // ------------------------------------------------------------------
545    // OVK outgoing-history round trip: an output built with the sender's
546    // OVK must recover (note, recipient, memo) under that same OVK — the
547    // Zcash convention that lets a wallet reconstruct its send history
548    // from chain data alone — and must stay opaque to any other OVK.
549    // ------------------------------------------------------------------
550
551    #[test]
552    fn output_built_with_sender_ovk_recovers_under_that_ovk_only() {
553        use grovedb_commitment_tree::{try_output_recovery_with_ovk, OrchardDomain, Scope};
554
555        let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key bytes");
556        let sender_ovk = FullViewingKey::from(&sk).to_ovk(Scope::External);
557
558        let recipient = test_orchard_address();
559        let amount = 31_337u64;
560        let mut memo = [0u8; 36];
561        memo[..9].copy_from_slice(b"ovk-round");
562
563        let bundle = build_output_only_bundle(
564            &recipient,
565            amount,
566            memo,
567            Some(sender_ovk.clone()),
568            0,
569            &TestProver,
570        )
571        .expect("bundle should build");
572
573        let recover_all = |ovk: &grovedb_commitment_tree::OutgoingViewingKey| {
574            bundle
575                .actions()
576                .iter()
577                .filter_map(|action| {
578                    let domain = OrchardDomain::<DashMemo>::for_action(action);
579                    try_output_recovery_with_ovk(
580                        &domain,
581                        ovk,
582                        action,
583                        action.cv_net(),
584                        &action.encrypted_note().out_ciphertext,
585                    )
586                })
587                .collect::<Vec<_>>()
588        };
589
590        let recovered = recover_all(&sender_ovk);
591        assert_eq!(
592            recovered.len(),
593            1,
594            "exactly the real recipient output must recover; padding stays opaque"
595        );
596        let (note, recovered_addr, recovered_memo) = &recovered[0];
597        assert_eq!(note.value().inner(), amount, "recovered value mismatch");
598        assert_eq!(
599            recovered_addr.to_raw_address_bytes(),
600            recipient.inner().to_raw_address_bytes(),
601            "recovered recipient mismatch"
602        );
603        assert_eq!(*recovered_memo, memo, "recovered memo mismatch");
604
605        // A different wallet's OVK opens nothing — no false positives in
606        // anyone else's send history.
607        let other_sk = SpendingKey::from_bytes([7u8; 32]).expect("valid spending key bytes");
608        let other_ovk = FullViewingKey::from(&other_sk).to_ovk(Scope::External);
609        assert!(
610            recover_all(&other_ovk).is_empty(),
611            "a foreign OVK must not recover the output"
612        );
613    }
614
615    // ------------------------------------------------------------------
616    // `From<&OrchardAddress> for PaymentAddress` delegates to `inner()`.
617    // ------------------------------------------------------------------
618
619    #[test]
620    fn from_orchard_address_to_payment_address_preserves_bytes() {
621        let addr = test_orchard_address();
622        let pa: PaymentAddress = (&addr).into();
623        assert_eq!(
624            pa.to_raw_address_bytes(),
625            addr.inner().to_raw_address_bytes()
626        );
627    }
628
629    // ------------------------------------------------------------------
630    // `build_spend_bundle` — exercise the `add_spend` error path. The
631    // helper notes don't reconcile to `Anchor::empty_tree()` (the
632    // commitment and the all-zeros Merkle path don't match), so adding
633    // the spend surfaces an AnchorMismatch error wrapped in
634    // `ProtocolError::ShieldedBuildError`.
635    // ------------------------------------------------------------------
636
637    #[test]
638    fn build_spend_bundle_add_spend_anchor_mismatch_surfaces_error() {
639        let recipient = test_orchard_address();
640        let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key");
641        let fvk = FullViewingKey::from(&sk);
642        let ask = SpendAuthorizingKey::from(&sk);
643
644        let spends = vec![test_spendable_note(50_000)];
645
646        let result = build_spend_bundle(
647            spends,
648            &recipient,
649            40_000,
650            [1u8; 36],
651            &fvk,
652            &ask,
653            Anchor::empty_tree(),
654            &TestProver,
655            &[],
656        );
657        let err = result.expect_err("anchor mismatch should bubble up");
658        match err {
659            ProtocolError::ShieldedBuildError(msg) => {
660                assert!(
661                    msg.contains("failed to add spend")
662                        || msg.contains("AnchorMismatch")
663                        || msg.contains("anchor"),
664                    "unexpected error message: {}",
665                    msg
666                );
667            }
668            other => panic!("expected ShieldedBuildError, got {:?}", other),
669        }
670    }
671
672    #[test]
673    fn build_spend_bundle_empty_spends_still_returns_some_output_bundle_or_error() {
674        // Exercise the loop-never-executed branch: no spends at all. The
675        // Orchard builder configuration `BundleType::DEFAULT` requires at
676        // least one spend by default — expect an error wrapped as
677        // `ShieldedBuildError`.
678        let recipient = test_orchard_address();
679        let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid sk");
680        let fvk = FullViewingKey::from(&sk);
681        let ask = SpendAuthorizingKey::from(&sk);
682
683        let result = build_spend_bundle(
684            vec![],
685            &recipient,
686            0,
687            [0u8; 36],
688            &fvk,
689            &ask,
690            Anchor::empty_tree(),
691            &TestProver,
692            &[],
693        );
694        // Whatever the outcome, it should be deterministic: either Ok (with
695        // padding) or a clean ShieldedBuildError — never a panic.
696        match result {
697            Ok(_) => {}
698            Err(ProtocolError::ShieldedBuildError(_)) => {}
699            Err(e) => panic!("unexpected error kind: {:?}", e),
700        }
701    }
702
703    /// Builds an output-only builder the way `build_output_only_bundle` does (no merkle
704    /// witness needed): a single output, padded by `BundleType` to the 2-action minimum.
705    fn output_only_builder(amount: u64) -> Builder<DashMemo> {
706        let recipient = test_orchard_address();
707        let payment_address = PaymentAddress::from(&recipient);
708        let mut builder = Builder::<DashMemo>::new(
709            BundleType::Transactional {
710                flags: OrchardFlags::SPENDS_DISABLED,
711                bundle_required: false,
712            },
713            Anchor::empty_tree(),
714        );
715        builder
716            .add_output(
717                None,
718                payment_address,
719                NoteValue::from_raw(amount),
720                [0u8; 36],
721            )
722            .expect("add output");
723        builder
724    }
725
726    // ------------------------------------------------------------------
727    // `prove_and_sign_bundle_with` — the closure contract. The closure MUST
728    // receive the BUILT bundle's published action nullifiers (padding
729    // actions' dummy nullifiers included), in on-wire order: this is what
730    // lets `IdentityCreateFromShieldedPool` derive its identity id from the
731    // same nullifier set consensus re-derives it from. Deriving from the
732    // requested spends alone would diverge whenever the bundle is padded.
733    // ------------------------------------------------------------------
734
735    #[test]
736    fn prove_and_sign_bundle_with_closure_receives_published_nullifiers() {
737        let builder = output_only_builder(10_000);
738
739        let mut recorded: Option<Vec<[u8; 32]>> = None;
740        let bundle = prove_and_sign_bundle_with(builder, &TestProver, &[], |nullifiers| {
741            recorded = Some(nullifiers.to_vec());
742            Ok(vec![])
743        })
744        .expect("output-only bundle should prove");
745
746        let recorded = recorded.expect("the extra-sighash closure must run");
747        // A single output is padded to the 2-action minimum; every padded action
748        // publishes a (dummy) nullifier on the wire.
749        assert_eq!(
750            recorded.len(),
751            2,
752            "closure must see one nullifier per PUBLISHED action (incl. padding)"
753        );
754        assert_ne!(
755            recorded[0], recorded[1],
756            "padding dummy nullifiers are randomized per action"
757        );
758        // The recorded set must be exactly the authorized bundle's published
759        // nullifiers, in the same on-wire order.
760        let published: Vec<[u8; 32]> = bundle
761            .actions()
762            .iter()
763            .map(|action| action.nullifier().to_bytes())
764            .collect();
765        assert_eq!(
766            recorded, published,
767            "closure must receive the bundle's published nullifiers in on-wire order"
768        );
769    }
770
771    #[test]
772    fn prove_and_sign_bundle_with_closure_error_short_circuits_before_proving() {
773        let builder = output_only_builder(10_000);
774
775        let result = prove_and_sign_bundle_with(builder, &TestProver, &[], |_| {
776            Err(ProtocolError::ShieldedBuildError(
777                "closure rejected".to_string(),
778            ))
779        });
780
781        match result {
782            Err(ProtocolError::ShieldedBuildError(msg)) => {
783                assert_eq!(msg, "closure rejected", "closure error must pass through");
784            }
785            other => panic!("expected the closure's error to propagate, got {:?}", other),
786        }
787    }
788}