Skip to main content

dpp/shielded/builder/
identity_create_from_shielded_pool.rs

1use grovedb_commitment_tree::{Anchor, FullViewingKey, SpendAuthorizingKey};
2
3use crate::address_funds::OrchardAddress;
4use crate::address_funds::PlatformAddress;
5use crate::fee::Credits;
6use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0;
7use crate::identity::signer::Signer;
8use crate::identity::IdentityPublicKey;
9use crate::serialization::Signable;
10use crate::shielded::compute_shielded_identity_create_fee;
11use crate::state_transition::public_key_in_creation::accessors::{
12    IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV0Setters,
13};
14use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation;
15use crate::shielded::OrchardBundleParams;
16use crate::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::methods::IdentityCreateFromShieldedPoolTransitionMethodsV0;
17use crate::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::{
18    derive_identity_id_from_actions, identity_id_from_nullifiers,
19    IdentityCreateFromShieldedPoolTransition,
20};
21use crate::state_transition::StateTransition;
22use crate::ProtocolError;
23use platform_value::Identifier;
24use platform_version::version::PlatformVersion;
25
26use super::{build_spend_bundle_with, serialize_authorized_bundle, OrchardProver, SpendableNote};
27
28/// Output of [`build_identity_create_from_shielded_pool_transition`]: everything the SDK's
29/// `IdentityCreateFromShieldedPool::identity_create_from_shielded_pool` broadcast helper needs.
30///
31/// The split (PoP-signed keys + bundle params, rather than a fully-built `StateTransition`) lets
32/// the wallet feed the SDK helper directly — the helper re-assembles the transition via
33/// `try_from_bundle`, which preserves the per-key proof-of-possession signatures already filled here.
34pub struct IdentityCreateFromShieldedPoolBuildResult {
35    /// The new identity's public keys with their per-key proof-of-possession signatures filled.
36    pub public_keys: Vec<IdentityPublicKeyInCreation>,
37    /// The serialized, authorized Orchard bundle (actions / anchor / proof / binding signature).
38    pub bundle: OrchardBundleParams,
39    /// The new identity's id (`double_sha256(sorted nullifiers)`), surfaced so the host can persist
40    /// / display it without re-deriving.
41    pub identity_id: Identifier,
42    /// The client-predicted fee (in credits). The authoritative fee is metered at consensus.
43    pub predicted_fee: Credits,
44}
45
46/// Builds an `IdentityCreateFromShieldedPool` (Type 20) state transition: spend shielded-pool
47/// notes to fund a brand-new Platform identity.
48///
49/// The `denomination` (a member of the versioned exit-denomination set) leaves the pool EXACTLY —
50/// the bundle's `value_balance` equals `denomination` (the ShieldedTransfer exact-equality model).
51/// Any spent value above the denomination re-enters the pool as a single change note to
52/// `change_address`. The metered fee is taken from the denomination at execution, so the new
53/// identity is created holding `denomination - total_fee` (the fee is NOT subtracted from the
54/// bundle here — only predicted for the caller's note-reservation math).
55///
56/// # Authorization
57///
58/// `IdentityCreateFromShieldedPool` carries NO platform identity signature. Authorization is 100%:
59/// 1. the Orchard proof + per-action spend-auth signatures (the spender controls the spent notes),
60/// 2. the RedPallas binding signature over the platform sighash, which commits the new identity id,
61///    the denomination, and the FULL public-key set via
62///    [`crate::shielded::identity_create_from_shielded_extra_sighash_data`] — so a relayer cannot
63///    redirect the bundle to a different id or swap in keys it controls, and
64/// 3. a per-key proof-of-possession signature over the transition's `signable_bytes`, proving the
65///    creator holds every key being registered (mirrors `IdentityCreate`).
66///
67/// The new identity id is derived from the SORTED **published** action nullifiers
68/// ([`derive_identity_id_from_actions`]) — including any padding action's dummy nullifier
69/// (`BundleType::DEFAULT` pads single-spend bundles to a 2-action minimum), so it is only known
70/// once the bundle's action set is fixed. It is derived inside the bundle-build hook, bound into
71/// the Orchard sighash there, and the same value is re-derived and checked at consensus.
72///
73/// # Parameters
74/// - `public_keys` — the new identity's public keys, each paired with its
75///   [`IdentityPublicKeyInCreation`] form (the latter goes into the transition; the former is used
76///   only to look up the private key in `identity_signer`). The per-key proof-of-possession
77///   signatures are filled by this function.
78/// - `denomination` — the fixed exit amount (in credits) leaving the pool.
79/// - `spends` — notes to spend with their Merkle paths. Their total MUST be `>= denomination`.
80/// - `change_address` — Orchard address that receives the change note (`total_spent - denomination`).
81/// - `fvk` / `ask` — the spender's full viewing key and spend-authorizing key (Orchard side).
82/// - `anchor` — Sinsemilla root of the note commitment tree (Orchard Anchor).
83/// - `prover` — Orchard prover (holds the Halo 2 proving key).
84/// - `identity_signer` — produces each new key's proof-of-possession signature over the transition's
85///   signable bytes.
86/// - `memo` — 36-byte structured memo for the change output.
87/// - `platform_version` — protocol version.
88///
89/// Returns the PoP-signed keys, the serialized Orchard bundle, the derived identity id, and the
90/// client-predicted fee (in credits) — ready to feed the SDK's
91/// `IdentityCreateFromShieldedPool::identity_create_from_shielded_pool` broadcast helper. The
92/// authoritative fee is metered at consensus.
93#[allow(clippy::too_many_arguments)]
94pub async fn build_identity_create_from_shielded_pool_transition<P, S>(
95    public_keys: Vec<(IdentityPublicKey, IdentityPublicKeyInCreation)>,
96    denomination: u64,
97    send_to_address_on_creation_failure: PlatformAddress,
98    spends: Vec<SpendableNote>,
99    change_address: &OrchardAddress,
100    fvk: &FullViewingKey,
101    ask: &SpendAuthorizingKey,
102    anchor: Anchor,
103    prover: &P,
104    identity_signer: &S,
105    memo: [u8; 36],
106    platform_version: &PlatformVersion,
107) -> Result<IdentityCreateFromShieldedPoolBuildResult, ProtocolError>
108where
109    P: OrchardProver,
110    S: Signer<IdentityPublicKey>,
111{
112    if denomination > i64::MAX as u64 {
113        return Err(ProtocolError::ShieldedBuildError(format!(
114            "denomination {} exceeds maximum allowed value {}",
115            denomination,
116            i64::MAX as u64
117        )));
118    }
119    if public_keys.is_empty() {
120        return Err(ProtocolError::ShieldedBuildError(
121            "identity-create-from-shielded-pool requires at least one public key".to_string(),
122        ));
123    }
124
125    // Reject a non-member denomination before any (expensive) proving — Type 20 exits are a
126    // protocol-versioned fixed set, so an unsupported value would be rejected at `validate_structure`
127    // after the Orchard proof anyway. Fail fast.
128    let allowed_denominations = platform_version
129        .drive_abci
130        .validation_and_processing
131        .event_constants
132        .shielded_identity_create_denominations;
133    if !allowed_denominations.contains(&denomination) {
134        return Err(ProtocolError::ShieldedBuildError(format!(
135            "denomination {denomination} is not a member of the allowed exit-denomination set {allowed_denominations:?}"
136        )));
137    }
138
139    // Checked: a large spend set could otherwise overflow u64 (release builds wrap silently).
140    let total_spent = spends
141        .iter()
142        .try_fold(0u64, |acc, s| acc.checked_add(s.note.value().inner()))
143        .ok_or_else(|| {
144            ProtocolError::ShieldedBuildError(
145                "identity-create-from-shielded-pool total spent value overflows u64".to_string(),
146            )
147        })?;
148    if denomination > total_spent {
149        return Err(ProtocolError::ShieldedBuildError(format!(
150            "denomination {} exceeds total spendable value {}",
151            denomination, total_spent
152        )));
153    }
154
155    // The whole denomination leaves the pool; the excess re-enters as a single change note. There
156    // is NO shielded recipient — the value funds the (transparent) new identity, not another note.
157    // Cannot underflow: the `denomination > total_spent` guard above already rejected that case.
158    let change_amount = total_spent - denomination;
159
160    // Orchard's BundleType::DEFAULT pads single-spend bundles to a 2-action minimum, matching the
161    // other spend-side builders. The fee predictor is only informational here (the metered fee at
162    // execution is authoritative); we report it so the caller's reservation math lines up.
163    let num_actions = spends.len().max(2);
164    let fee =
165        compute_shielded_identity_create_fee(num_actions, public_keys.len(), platform_version)?;
166
167    // The metered fee is carved from the denomination at execution; if the predicted fee already
168    // meets/exceeds it, the new identity could not be created with a positive balance (consensus
169    // rejects `total_fee >= denomination`). Fail fast rather than after proving.
170    if fee >= denomination {
171        return Err(ProtocolError::ShieldedBuildError(format!(
172            "predicted fee {fee} is not less than the denomination {denomination}; the new identity would have a non-positive balance"
173        )));
174    }
175
176    // Build the in-creation key list (transition order) up front — it is bound, together with the
177    // id and the denomination, into the Orchard sighash.
178    let in_creation_keys: Vec<IdentityPublicKeyInCreation> =
179        public_keys.iter().map(|(_, c)| c.clone()).collect();
180
181    // The id is `double_sha256(sorted PUBLISHED nullifiers)`. The published set is only known once
182    // the bundle is built: `BundleType::DEFAULT` pads a single-spend bundle with a dummy action
183    // whose random nullifier goes on the wire, and consensus re-derives the id over ALL action
184    // nullifiers (dummies are indistinguishable by design). So derive the id inside the
185    // post-build hook — after the action set is fixed, before the sighash is bound.
186    let mut bound_identity_id: Option<Identifier> = None;
187    // Consensus refuses a key bound to a contract group in this transition (its Orchard sighash
188    // layout predates group bounds); refuse it here before a proof is generated.
189    if let Some(key) =
190        IdentityPublicKeyInCreation::first_bound_to_a_contract_group(&in_creation_keys)
191    {
192        return Err(ProtocolError::ShieldedBuildError(format!(
193            "key {} is bound to a contract group, which an identity created from the shielded \
194             pool cannot register; add it with an identity update",
195            key.id()
196        )));
197    }
198    // Likewise for a key that carries a budget or an expiry: neither is in the sighash layout,
199    // so it would not be bound to the spend. A version 1 key without limits is accepted.
200    if let Some(key) = IdentityPublicKeyInCreation::first_with_limits(&in_creation_keys) {
201        return Err(ProtocolError::ShieldedBuildError(format!(
202            "key {} carries a budget or an expiry, which an identity created from the shielded \
203             pool cannot register; add it with an identity update",
204            key.id()
205        )));
206    }
207
208    let bundle = build_spend_bundle_with(
209        spends,
210        change_address,
211        change_amount,
212        memo,
213        fvk,
214        ask,
215        anchor,
216        prover,
217        |published_nullifiers| {
218            let id = identity_id_from_nullifiers(published_nullifiers);
219            let data = crate::shielded::identity_create_from_shielded_extra_sighash_data(
220                &id.to_buffer(),
221                denomination,
222                &send_to_address_on_creation_failure,
223                &in_creation_keys,
224                platform_version,
225            )?;
226            bound_identity_id = Some(id);
227            Ok(data)
228        },
229    )?;
230    let identity_id = bound_identity_id.ok_or_else(|| {
231        ProtocolError::ShieldedBuildError(
232            "identity id was not derived during bundle build".to_string(),
233        )
234    })?;
235
236    let sb = serialize_authorized_bundle(&bundle);
237
238    // The consensus binding re-derives the id from the on-wire action nullifiers. Assert the
239    // bundle's published nullifiers reduce to the same id we bound, so a mismatch is caught here
240    // (cheap) rather than as an opaque InvalidShieldedProofError after the ~30 s proof.
241    if identity_id != derive_identity_id_from_actions(&sb.actions) {
242        return Err(ProtocolError::ShieldedBuildError(
243            "bound identity id does not match the id re-derived from the bundle's published \
244             nullifiers"
245                .to_string(),
246        ));
247    }
248
249    // Build the transition (denomination == value_balance EXACTLY) with the unsigned key set, purely
250    // to obtain the canonical signable bytes the per-key proofs-of-possession must sign.
251    let mut state_transition = IdentityCreateFromShieldedPoolTransition::try_from_bundle(
252        in_creation_keys,
253        denomination,
254        send_to_address_on_creation_failure,
255        sb.actions.clone(),
256        sb.anchor,
257        sb.proof.clone(),
258        sb.binding_signature,
259        platform_version,
260    )?;
261
262    // Per-key proof-of-possession: each unique-type key signs the transition's signable bytes. The
263    // signable form excludes the per-key signatures themselves (and the derived identity id), so the
264    // bytes are stable across the signing loop — compute them once, mirroring `IdentityCreate`.
265    let key_signable_bytes = state_transition.signable_bytes()?;
266
267    let StateTransition::IdentityCreateFromShieldedPool(
268        IdentityCreateFromShieldedPoolTransition::V0(v0),
269    ) = &mut state_transition
270    else {
271        return Err(ProtocolError::ShieldedBuildError(
272            "unexpected state transition variant after try_from_bundle".to_string(),
273        ));
274    };
275
276    for (key_with_witness, (original_key, _)) in v0.public_keys.iter_mut().zip(public_keys.iter()) {
277        if original_key.key_type().is_unique_key_type() {
278            let signature = identity_signer
279                .sign(original_key, &key_signable_bytes)
280                .await?;
281            key_with_witness.set_signature(signature);
282        }
283    }
284
285    // Hand the PoP-signed keys + the bundle params back to the caller (the wallet), which feeds them
286    // to the SDK broadcast helper. The helper re-assembles the transition via `try_from_bundle`,
287    // preserving these signatures.
288    let signed_public_keys = std::mem::take(&mut v0.public_keys);
289
290    Ok(IdentityCreateFromShieldedPoolBuildResult {
291        public_keys: signed_public_keys,
292        bundle: OrchardBundleParams {
293            actions: sb.actions,
294            anchor: sb.anchor,
295            proof: sb.proof,
296            binding_signature: sb.binding_signature,
297        },
298        identity_id,
299        predicted_fee: fee,
300    })
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::address_funds::AddressWitness;
307    use crate::identity::identity_public_key::v0::IdentityPublicKeyV0;
308    use crate::identity::{KeyType, Purpose, SecurityLevel};
309    use crate::shielded::builder::test_helpers::{
310        test_orchard_address, test_spendable_note, TestProver,
311    };
312    use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0;
313    use grovedb_commitment_tree::{
314        ExtractedNoteCommitment, Hashable, MerkleHashOrchard, MerklePath, SpendingKey,
315        NOTE_COMMITMENT_TREE_DEPTH,
316    };
317    use platform_value::BinaryData;
318
319    /// A dummy PoP signer producing a fixed 65-byte signature. The builder fills (and does not
320    /// verify) the proof-of-possession signatures, so a stub is enough to exercise the pipeline.
321    #[derive(Debug)]
322    struct DummySigner;
323
324    #[async_trait::async_trait]
325    impl Signer<IdentityPublicKey> for DummySigner {
326        async fn sign(
327            &self,
328            _key: &IdentityPublicKey,
329            _data: &[u8],
330        ) -> Result<BinaryData, ProtocolError> {
331            Ok(BinaryData::new(vec![0u8; 65]))
332        }
333
334        async fn sign_create_witness(
335            &self,
336            _key: &IdentityPublicKey,
337            _data: &[u8],
338        ) -> Result<AddressWitness, ProtocolError> {
339            Err(ProtocolError::ShieldedBuildError(
340                "identity PoP signer never creates address witnesses".to_string(),
341            ))
342        }
343
344        fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool {
345            true
346        }
347    }
348
349    /// One AUTHENTICATION/MASTER ECDSA key in both forms the builder takes.
350    fn key_pair(id: u32) -> (IdentityPublicKey, IdentityPublicKeyInCreation) {
351        let public = IdentityPublicKey::V0(IdentityPublicKeyV0 {
352            id,
353            purpose: Purpose::AUTHENTICATION,
354            security_level: SecurityLevel::MASTER,
355            contract_bounds: None,
356            key_type: KeyType::ECDSA_SECP256K1,
357            read_only: false,
358            data: BinaryData::new(vec![0xAB; 33]),
359            disabled_at: None,
360        });
361        let in_creation = IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 {
362            id,
363            key_type: KeyType::ECDSA_SECP256K1,
364            purpose: Purpose::AUTHENTICATION,
365            security_level: SecurityLevel::MASTER,
366            contract_bounds: None,
367            read_only: false,
368            data: BinaryData::new(vec![0xAB; 33]),
369            signature: BinaryData::new(vec![]),
370        });
371        (public, in_creation)
372    }
373
374    /// 0.1 DASH in credits — the smallest member of the versioned exit-denomination set.
375    const DENOMINATION: u64 = 10_000_000_000;
376
377    /// A second, HIGH level key in the version 1 format, in both forms the builder takes.
378    fn version_1_key_pair(
379        id: u32,
380        total_budget: Option<u64>,
381        expires_at: Option<u64>,
382    ) -> (IdentityPublicKey, IdentityPublicKeyInCreation) {
383        let public = IdentityPublicKey::V0(IdentityPublicKeyV0 {
384            id,
385            purpose: Purpose::AUTHENTICATION,
386            security_level: SecurityLevel::HIGH,
387            contract_bounds: None,
388            key_type: KeyType::ECDSA_HASH160,
389            read_only: false,
390            data: BinaryData::new(vec![0xCD; 20]),
391            disabled_at: None,
392        })
393        .with_limits(total_budget, expires_at);
394        let in_creation = IdentityPublicKeyInCreation::from(&public);
395        (public, in_creation)
396    }
397
398    /// Consensus refuses a key that carries a budget or an expiry in this transition, because the
399    /// Orchard sighash does not cover them. The builder refuses it up front, before a proof is
400    /// generated, and builds a version 1 key without limits like any other key.
401    #[tokio::test]
402    async fn should_refuse_a_key_with_limits_and_build_a_version_1_key_without() {
403        let platform_version = PlatformVersion::latest();
404        let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key");
405        let fvk = FullViewingKey::from(&sk);
406        let ask = SpendAuthorizingKey::from(&sk);
407        let change_address = test_orchard_address();
408
409        for (total_budget, expires_at, refused) in [
410            (Some(1_000), None, true),
411            (None, Some(2_000), true),
412            (None, None, false),
413        ] {
414            let spend = test_spendable_note(12_000_000_000);
415            let cmx = ExtractedNoteCommitment::from(spend.note.commitment());
416            let anchor = spend.merkle_path.root(cmx);
417
418            let result = build_identity_create_from_shielded_pool_transition(
419                vec![key_pair(0), version_1_key_pair(1, total_budget, expires_at)],
420                DENOMINATION,
421                PlatformAddress::P2pkh([0u8; 20]),
422                vec![spend],
423                &change_address,
424                &fvk,
425                &ask,
426                anchor,
427                &TestProver,
428                &DummySigner,
429                [0u8; 36],
430                platform_version,
431            )
432            .await;
433
434            if refused {
435                assert!(
436                    matches!(
437                        &result,
438                        Err(ProtocolError::ShieldedBuildError(message))
439                            if message.contains("carries a budget or an expiry")
440                    ),
441                    "a key with limits must be refused, got {:?}",
442                    result.map(|_| "a built transition")
443                );
444            } else {
445                result.expect("a version 1 key without limits must build");
446            }
447        }
448    }
449
450    /// The padded-bundle regression test for the dummy-nullifier bug: a SINGLE-spend bundle is
451    /// padded by `BundleType::DEFAULT` to the 2-action minimum, and the padding action's random
452    /// dummy nullifier is published on the wire. The identity id MUST be derived from the FULL
453    /// published set (what consensus re-derives), not from the real spends alone — pre-fix this
454    /// build failed its own post-proving id-consistency check on every 1-note spend.
455    #[tokio::test]
456    async fn single_spend_padded_bundle_derives_id_from_published_nullifiers() {
457        let platform_version = PlatformVersion::latest();
458        let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key");
459        let fvk = FullViewingKey::from(&sk);
460        let ask = SpendAuthorizingKey::from(&sk);
461        let change_address = test_orchard_address();
462
463        // A valid (note, path, anchor) triple: the anchor is the root the witness computes over
464        // the note's commitment — the same trick `extract_spends_and_anchor` uses in production.
465        let spend = test_spendable_note(12_000_000_000);
466        let cmx = ExtractedNoteCommitment::from(spend.note.commitment());
467        let anchor = spend.merkle_path.root(cmx);
468        let real_nullifier = spend.note.nullifier(&fvk).to_bytes();
469
470        let result = build_identity_create_from_shielded_pool_transition(
471            vec![key_pair(0)],
472            DENOMINATION,
473            PlatformAddress::P2pkh([0u8; 20]),
474            vec![spend],
475            &change_address,
476            &fvk,
477            &ask,
478            anchor,
479            &TestProver,
480            &DummySigner,
481            [0u8; 36],
482            platform_version,
483        )
484        .await
485        .expect("a single-spend (padded) build must succeed");
486
487        assert_eq!(
488            result.bundle.actions.len(),
489            2,
490            "a single spend must be padded to the 2-action minimum"
491        );
492        assert!(
493            result
494                .bundle
495                .actions
496                .iter()
497                .any(|action| action.nullifier == real_nullifier),
498            "the real spend's nullifier must be among the published actions"
499        );
500        // The id must equal the consensus re-derivation over ALL published nullifiers…
501        assert_eq!(
502            result.identity_id,
503            derive_identity_id_from_actions(&result.bundle.actions),
504            "identity id must match the consensus derivation over the published actions"
505        );
506        // …and must NOT equal the real-spends-only derivation (the pre-fix behavior): the padding
507        // action's dummy nullifier participates.
508        assert_ne!(
509            result.identity_id,
510            identity_id_from_nullifiers(&[real_nullifier]),
511            "the padding action's dummy nullifier must participate in the id derivation"
512        );
513        assert!(
514            result.predicted_fee < DENOMINATION,
515            "predicted fee must leave the new identity a positive balance"
516        );
517    }
518
519    /// Complement: with two real spends (no padding needed), the published set IS the real set,
520    /// so the id equals the real-nullifiers-only derivation.
521    #[tokio::test]
522    async fn two_spend_unpadded_bundle_id_matches_real_nullifier_derivation() {
523        let platform_version = PlatformVersion::latest();
524        let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key");
525        let fvk = FullViewingKey::from(&sk);
526        let ask = SpendAuthorizingKey::from(&sk);
527        let change_address = test_orchard_address();
528
529        // Two distinct notes (different values → different commitments/nullifiers) witnessed in
530        // one two-leaf tree: each path's level-0 sibling is the other leaf, upper siblings shared,
531        // so both witnesses compute the SAME root — a consistent shared anchor.
532        let note_a = test_spendable_note(6_000_000_000).note;
533        let note_b = test_spendable_note(7_000_000_000).note;
534        let cmx_a = ExtractedNoteCommitment::from(note_a.commitment());
535        let cmx_b = ExtractedNoteCommitment::from(note_b.commitment());
536
537        let mut auth_path_a = [MerkleHashOrchard::empty_leaf(); NOTE_COMMITMENT_TREE_DEPTH];
538        auth_path_a[0] = MerkleHashOrchard::from_cmx(&cmx_b);
539        let mut auth_path_b = [MerkleHashOrchard::empty_leaf(); NOTE_COMMITMENT_TREE_DEPTH];
540        auth_path_b[0] = MerkleHashOrchard::from_cmx(&cmx_a);
541        let path_a = MerklePath::from_parts(0, auth_path_a);
542        let path_b = MerklePath::from_parts(1, auth_path_b);
543
544        let anchor = path_a.root(cmx_a);
545        assert_eq!(
546            anchor.to_bytes(),
547            path_b.root(cmx_b).to_bytes(),
548            "both witnesses must compute the same anchor"
549        );
550
551        let nf_a = note_a.nullifier(&fvk).to_bytes();
552        let nf_b = note_b.nullifier(&fvk).to_bytes();
553        let spends = vec![
554            SpendableNote {
555                note: note_a,
556                merkle_path: path_a,
557            },
558            SpendableNote {
559                note: note_b,
560                merkle_path: path_b,
561            },
562        ];
563
564        let result = build_identity_create_from_shielded_pool_transition(
565            vec![key_pair(0)],
566            DENOMINATION,
567            PlatformAddress::P2pkh([0u8; 20]),
568            spends,
569            &change_address,
570            &fvk,
571            &ask,
572            anchor,
573            &TestProver,
574            &DummySigner,
575            [0u8; 36],
576            platform_version,
577        )
578        .await
579        .expect("a two-spend build must succeed");
580
581        assert_eq!(
582            result.bundle.actions.len(),
583            2,
584            "two spends + one change output need no padding"
585        );
586        assert_eq!(
587            result.identity_id,
588            derive_identity_id_from_actions(&result.bundle.actions),
589            "identity id must match the consensus derivation over the published actions"
590        );
591        assert_eq!(
592            result.identity_id,
593            identity_id_from_nullifiers(&[nf_a, nf_b]),
594            "with no padding, the published set is exactly the real spends' nullifiers"
595        );
596    }
597}