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