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