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
28pub struct IdentityCreateFromShieldedPoolBuildResult {
35 pub public_keys: Vec<IdentityPublicKeyInCreation>,
37 pub bundle: OrchardBundleParams,
39 pub identity_id: Identifier,
42 pub predicted_fee: Credits,
44}
45
46#[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 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 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 let change_amount = total_spent - denomination;
159
160 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 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 let in_creation_keys: Vec<IdentityPublicKeyInCreation> =
179 public_keys.iter().map(|(_, c)| c.clone()).collect();
180
181 let mut bound_identity_id: Option<Identifier> = None;
187 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 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 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 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 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 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 #[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 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 const DENOMINATION: u64 = 10_000_000_000;
376
377 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 #[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 #[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 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 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 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 #[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 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}