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